react-realtime-hooks 1.0.1 → 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
 
@@ -507,7 +512,7 @@ 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 moves into `error` and stores `lastError`.
515
+ - If `parseMessage` throws, the hook closes the current transport, moves into `error`, stores `lastError`, and stops auto-reconnect until manual `open()` or `reconnect()`.
511
516
  - `connect: false` keeps the hook in `idle` until `open()` is called.
512
517
  - Manual `close()` is sticky. The hook stays closed until `open()` or `reconnect()` is called.
513
518
  - 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,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,8 @@ 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);
653
+ const terminalErrorRef = react.useRef(null);
621
654
  const [openNonce, setOpenNonce] = react.useState(0);
622
655
  const [state, setState] = react.useState(
623
656
  () => createInitialState3(connect ? "connecting" : "idle")
@@ -635,6 +668,7 @@ var useWebSocket = (options) => {
635
668
  const heartbeatConfig = toHeartbeatConfig(
636
669
  options.heartbeat
637
670
  );
671
+ const defaultHeartbeatAction = options.reconnect === false ? "close" : "reconnect";
638
672
  const heartbeatHookOptions = heartbeatConfig === null ? {
639
673
  enabled: false,
640
674
  intervalMs: 1e3,
@@ -670,6 +704,27 @@ var useWebSocket = (options) => {
670
704
  if (heartbeatConfig !== null && heartbeatConfig.onTimeout !== void 0) {
671
705
  heartbeatHookOptions.onTimeout = heartbeatConfig.onTimeout;
672
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
+ }
673
728
  const heartbeat = useHeartbeat(
674
729
  heartbeatHookOptions
675
730
  );
@@ -685,10 +740,46 @@ var useWebSocket = (options) => {
685
740
  }
686
741
  socketRef.current = null;
687
742
  socketKeyRef.current = null;
688
- if (socket2.readyState === WebSocket.OPEN || socket2.readyState === WebSocket.CONNECTING) {
743
+ if (isSocketActive(socket2)) {
689
744
  socket2.close(code, reason);
690
745
  }
691
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
+ );
692
783
  const parseMessage = react.useEffectEvent((event) => {
693
784
  const parser = options.parseMessage ?? defaultParseMessage;
694
785
  return parser(event);
@@ -701,7 +792,9 @@ var useWebSocket = (options) => {
701
792
  });
702
793
  const handleOpen = react.useEffectEvent((event, socket2) => {
703
794
  manualCloseRef.current = false;
795
+ manualOpenRef.current = false;
704
796
  suppressReconnectRef.current = false;
797
+ terminalErrorRef.current = null;
705
798
  reconnect.markConnected();
706
799
  heartbeat.start();
707
800
  commitState((current) => ({
@@ -725,11 +818,19 @@ var useWebSocket = (options) => {
725
818
  options.onMessage?.(message, event);
726
819
  } catch {
727
820
  const parseError = new Event("error");
821
+ terminalErrorRef.current = parseError;
822
+ manualOpenRef.current = false;
823
+ skipCloseReconnectRef.current = true;
824
+ suppressReconnectRef.current = true;
825
+ reconnect.cancel();
826
+ heartbeat.stop();
728
827
  commitState((current) => ({
729
828
  ...current,
829
+ lastChangedAt: Date.now(),
730
830
  lastError: parseError,
731
831
  status: "error"
732
832
  }));
833
+ closeSocket(1003, "parse-error");
733
834
  }
734
835
  });
735
836
  const handleError = react.useEffectEvent((event) => {
@@ -746,8 +847,38 @@ var useWebSocket = (options) => {
746
847
  socketKeyRef.current = null;
747
848
  heartbeat.stop();
748
849
  updateBufferedAmount();
850
+ const pendingCloseAction = pendingCloseActionRef.current;
851
+ pendingCloseActionRef.current = null;
852
+ const terminalError = terminalErrorRef.current;
749
853
  const skipCloseReconnect = skipCloseReconnectRef.current;
750
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
+ }
870
+ if (terminalError !== null) {
871
+ suppressReconnectRef.current = false;
872
+ commitState((current) => ({
873
+ ...current,
874
+ lastChangedAt: Date.now(),
875
+ lastCloseEvent: event,
876
+ lastError: terminalError,
877
+ status: "error"
878
+ }));
879
+ options.onClose?.(event);
880
+ return;
881
+ }
751
882
  const shouldReconnect = !suppressReconnectRef.current && !skipCloseReconnect && reconnectEnabled && (options.shouldReconnect?.(event) ?? true);
752
883
  commitState((current) => ({
753
884
  ...current,
@@ -766,6 +897,7 @@ var useWebSocket = (options) => {
766
897
  manualCloseRef.current = false;
767
898
  manualOpenRef.current = true;
768
899
  suppressReconnectRef.current = false;
900
+ terminalErrorRef.current = null;
769
901
  reconnect.cancel();
770
902
  setOpenNonce((current) => current + 1);
771
903
  };
@@ -774,6 +906,7 @@ var useWebSocket = (options) => {
774
906
  manualOpenRef.current = true;
775
907
  skipCloseReconnectRef.current = true;
776
908
  suppressReconnectRef.current = true;
909
+ terminalErrorRef.current = null;
777
910
  heartbeat.stop();
778
911
  closeSocket();
779
912
  suppressReconnectRef.current = false;
@@ -783,6 +916,7 @@ var useWebSocket = (options) => {
783
916
  manualCloseRef.current = true;
784
917
  manualOpenRef.current = false;
785
918
  suppressReconnectRef.current = true;
919
+ terminalErrorRef.current = null;
786
920
  reconnect.cancel();
787
921
  heartbeat.stop();
788
922
  commitState((current) => ({
@@ -820,7 +954,7 @@ var useWebSocket = (options) => {
820
954
  }));
821
955
  return;
822
956
  }
823
- const shouldConnect = connect && !manualCloseRef.current || manualOpenRef.current || reconnect.status === "running";
957
+ const shouldConnect = terminalErrorRef.current === null && (connect && !manualCloseRef.current || manualOpenRef.current || reconnect.status === "running");
824
958
  const nextSocketKey = `${resolvedUrl}::${protocolsDependency}::${options.binaryType ?? "blob"}`;
825
959
  if (!shouldConnect) {
826
960
  if (socketRef.current !== null) {
@@ -830,7 +964,7 @@ var useWebSocket = (options) => {
830
964
  socketKeyRef.current = null;
831
965
  commitState((current) => ({
832
966
  ...current,
833
- status: manualCloseRef.current ? "closed" : "idle"
967
+ status: terminalErrorRef.current !== null ? "error" : manualCloseRef.current ? "closed" : "idle"
834
968
  }));
835
969
  return;
836
970
  }
@@ -885,12 +1019,13 @@ var useWebSocket = (options) => {
885
1019
  react.useEffect(() => () => {
886
1020
  suppressReconnectRef.current = true;
887
1021
  socketKeyRef.current = null;
1022
+ terminalErrorRef.current = null;
888
1023
  const socket2 = socketRef.current;
889
1024
  socketRef.current = null;
890
1025
  if (socket2 === null) {
891
1026
  return;
892
1027
  }
893
- if (socket2.readyState === WebSocket.OPEN || socket2.readyState === WebSocket.CONNECTING) {
1028
+ if (isSocketActive(socket2)) {
894
1029
  socket2.close();
895
1030
  }
896
1031
  }, []);
@@ -981,6 +1116,7 @@ var useEventSource = (options) => {
981
1116
  const manualOpenRef = react.useRef(false);
982
1117
  const skipErrorReconnectRef = react.useRef(false);
983
1118
  const suppressReconnectRef = react.useRef(false);
1119
+ const terminalErrorRef = react.useRef(null);
984
1120
  const [openNonce, setOpenNonce] = react.useState(0);
985
1121
  const [state, setState] = react.useState(
986
1122
  () => createInitialState4(connect ? "connecting" : "idle")
@@ -1014,7 +1150,9 @@ var useEventSource = (options) => {
1014
1150
  });
1015
1151
  const handleOpen = react.useEffectEvent((event, source) => {
1016
1152
  manualCloseRef.current = false;
1153
+ manualOpenRef.current = false;
1017
1154
  suppressReconnectRef.current = false;
1155
+ terminalErrorRef.current = null;
1018
1156
  reconnect.markConnected();
1019
1157
  commitState((current) => ({
1020
1158
  ...current,
@@ -1040,8 +1178,14 @@ var useEventSource = (options) => {
1040
1178
  options.onMessage?.(message, event);
1041
1179
  } catch {
1042
1180
  const parseError = new Event("error");
1181
+ terminalErrorRef.current = parseError;
1182
+ manualOpenRef.current = false;
1183
+ suppressReconnectRef.current = true;
1184
+ reconnect.cancel();
1185
+ closeEventSource();
1043
1186
  commitState((current) => ({
1044
1187
  ...current,
1188
+ lastChangedAt: Date.now(),
1045
1189
  lastError: parseError,
1046
1190
  status: "error"
1047
1191
  }));
@@ -1049,6 +1193,17 @@ var useEventSource = (options) => {
1049
1193
  }
1050
1194
  );
1051
1195
  const handleError = react.useEffectEvent((event, source) => {
1196
+ const terminalError = terminalErrorRef.current;
1197
+ if (terminalError !== null) {
1198
+ suppressReconnectRef.current = false;
1199
+ commitState((current) => ({
1200
+ ...current,
1201
+ lastChangedAt: Date.now(),
1202
+ lastError: terminalError,
1203
+ status: "error"
1204
+ }));
1205
+ return;
1206
+ }
1052
1207
  const skipErrorReconnect = skipErrorReconnectRef.current;
1053
1208
  skipErrorReconnectRef.current = false;
1054
1209
  const shouldReconnect = !suppressReconnectRef.current && !skipErrorReconnect && reconnectEnabled && (options.shouldReconnect?.(event) ?? true);
@@ -1075,6 +1230,7 @@ var useEventSource = (options) => {
1075
1230
  manualCloseRef.current = false;
1076
1231
  manualOpenRef.current = true;
1077
1232
  suppressReconnectRef.current = false;
1233
+ terminalErrorRef.current = null;
1078
1234
  reconnect.cancel();
1079
1235
  setOpenNonce((current) => current + 1);
1080
1236
  };
@@ -1083,6 +1239,7 @@ var useEventSource = (options) => {
1083
1239
  manualOpenRef.current = true;
1084
1240
  skipErrorReconnectRef.current = true;
1085
1241
  suppressReconnectRef.current = true;
1242
+ terminalErrorRef.current = null;
1086
1243
  closeEventSource();
1087
1244
  suppressReconnectRef.current = false;
1088
1245
  reconnect.schedule("manual");
@@ -1091,6 +1248,7 @@ var useEventSource = (options) => {
1091
1248
  manualCloseRef.current = true;
1092
1249
  manualOpenRef.current = false;
1093
1250
  suppressReconnectRef.current = true;
1251
+ terminalErrorRef.current = null;
1094
1252
  reconnect.cancel();
1095
1253
  closeEventSource();
1096
1254
  commitState((current) => ({
@@ -1117,7 +1275,7 @@ var useEventSource = (options) => {
1117
1275
  }));
1118
1276
  return;
1119
1277
  }
1120
- const shouldConnect = connect && !manualCloseRef.current || manualOpenRef.current || reconnect.status === "running";
1278
+ const shouldConnect = terminalErrorRef.current === null && (connect && !manualCloseRef.current || manualOpenRef.current || reconnect.status === "running");
1121
1279
  const nextEventSourceKey = [
1122
1280
  resolvedUrl,
1123
1281
  options.withCredentials ? "credentials" : "anonymous",
@@ -1131,7 +1289,7 @@ var useEventSource = (options) => {
1131
1289
  eventSourceKeyRef.current = null;
1132
1290
  commitState((current) => ({
1133
1291
  ...current,
1134
- status: manualCloseRef.current ? "closed" : "idle"
1292
+ status: terminalErrorRef.current !== null ? "error" : manualCloseRef.current ? "closed" : "idle"
1135
1293
  }));
1136
1294
  return;
1137
1295
  }
@@ -1193,6 +1351,7 @@ var useEventSource = (options) => {
1193
1351
  react.useEffect(() => () => {
1194
1352
  suppressReconnectRef.current = true;
1195
1353
  eventSourceKeyRef.current = null;
1354
+ terminalErrorRef.current = null;
1196
1355
  const source = eventSourceRef.current;
1197
1356
  eventSourceRef.current = null;
1198
1357
  if (source !== null) {