react-realtime-hooks 1.4.0 → 1.4.2

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
@@ -38,6 +38,7 @@ Real apps need:
38
38
  - Discriminated connection snapshots: `idle`, `connecting`, `open`, `reconnecting`, `closing`, `closed`, `error`.
39
39
  - First-class TypeScript support with generic message types and custom parsers/serializers.
40
40
  - SSR-safe by default. No browser-only globals are touched during server render.
41
+ - Strict Mode safe. Works correctly under React 18+ `<StrictMode>` and Next.js dev double-mount — see [Strict Mode Safety](#strict-mode-safety).
41
42
  - Zero runtime dependencies beyond React.
42
43
  - Manual controls stay available when you need them: `open()`, `close()`, `reconnect()`, `send()`.
43
44
 
@@ -655,6 +656,19 @@ That keeps the gate predictable when multiple blockers apply at once.
655
656
  - No transport polyfills are bundled. Provide your own runtime support where needed.
656
657
  - Browser-native transport constraints still apply: auth, proxy, CORS, and network policy are outside the hook's control.
657
658
 
659
+ ## Strict Mode Safety
660
+
661
+ All hooks are safe to use under React 18+ `<React.StrictMode>` and the Next.js App Router's dev mode, both of which intentionally mount → unmount → mount each component on first render to surface effect-cleanup bugs.
662
+
663
+ Concretely:
664
+
665
+ - `useWebSocket` and `useEventSource` defer the actual `new WebSocket(...)` / `new EventSource(...)` call by a microtask. If the component unmounts before that microtask runs (the Strict Mode discard mount), no transport is ever created. If the mount survives, exactly one transport is created — never two.
666
+ - All effects in the library tear down their timers, listeners, and transports synchronously in the cleanup function. There is no "zombie" `setInterval`, no orphaned `addEventListener`, no leaked `WebSocket` left in `CONNECTING` after a discarded mount.
667
+ - Latest-state refs are committed via `useInsertionEffect`, not by writing to `ref.current` during render. A discarded render never leaves the ref out of sync with the committed tree.
668
+ - The library's own test suite runs every hook test inside `<React.StrictMode>` by default, so any regression that only shows up under double-mount is caught in CI.
669
+
670
+ If you observe a Strict Mode regression — e.g. two simultaneous WebSocket connections, an `EventSource` that survives a closed component, or a heartbeat that keeps firing after unmount — please open an issue with a minimal repro. That class of bug is supposed to be impossible by construction, and we treat it as a correctness defect.
671
+
658
672
  ## Testing And Quality
659
673
 
660
674
  The package includes behavior tests for:
package/dist/index.cjs CHANGED
@@ -466,7 +466,9 @@ var useReconnect = (options = {}) => {
466
466
  () => createInitialState(normalizedOptions.enabled)
467
467
  );
468
468
  const stateRef = react.useRef(state);
469
- stateRef.current = state;
469
+ react.useInsertionEffect(() => {
470
+ stateRef.current = state;
471
+ });
470
472
  const commitState = (next) => {
471
473
  const resolved = typeof next === "function" ? next(stateRef.current) : next;
472
474
  stateRef.current = resolved;
@@ -599,7 +601,9 @@ var useHeartbeat = (options) => {
599
601
  () => createInitialState2(enabled && startOnMount)
600
602
  );
601
603
  const stateRef = react.useRef(state);
602
- stateRef.current = state;
604
+ react.useInsertionEffect(() => {
605
+ stateRef.current = state;
606
+ });
603
607
  const commitState = (next) => {
604
608
  const resolved = typeof next === "function" ? next(stateRef.current) : next;
605
609
  stateRef.current = resolved;
@@ -805,9 +809,95 @@ var resolveUrlProvider = (url) => {
805
809
  const resolved = typeof url === "function" ? url() : url;
806
810
  return normalizeResolvedUrl(resolved ?? null);
807
811
  };
812
+ var createInitialState3 = () => ({
813
+ manualClose: false,
814
+ manualOpen: false,
815
+ pendingCloseAction: null,
816
+ reconnectSuppressed: false,
817
+ skipNextCloseReconnect: false,
818
+ terminalError: null
819
+ });
820
+ var useWebSocketController = () => {
821
+ const ref = react.useRef(null);
822
+ if (ref.current === null) {
823
+ ref.current = createInitialState3();
824
+ }
825
+ const state = ref.current;
826
+ const controllerRef = react.useRef(null);
827
+ if (controllerRef.current === null) {
828
+ controllerRef.current = {
829
+ clearReconnectSuppression: () => {
830
+ state.reconnectSuppressed = false;
831
+ },
832
+ consumePendingCloseAction: () => {
833
+ const action = state.pendingCloseAction;
834
+ state.pendingCloseAction = null;
835
+ return action;
836
+ },
837
+ consumeSkipNextCloseReconnect: () => {
838
+ const skip = state.skipNextCloseReconnect;
839
+ state.skipNextCloseReconnect = false;
840
+ return skip;
841
+ },
842
+ hasManualCloseRequested: () => state.manualClose,
843
+ hasManualOpenRequested: () => state.manualOpen,
844
+ isReconnectSuppressed: () => state.reconnectSuppressed,
845
+ noteEffectInitiatedClose: () => {
846
+ state.reconnectSuppressed = true;
847
+ },
848
+ noteHeartbeatActiveSocketClose: (input) => {
849
+ state.manualOpen = false;
850
+ state.terminalError = input.reconnectTrigger === null ? input.error : null;
851
+ state.pendingCloseAction = input;
852
+ state.skipNextCloseReconnect = true;
853
+ state.reconnectSuppressed = true;
854
+ },
855
+ noteHeartbeatNoActiveSocket: ({ shouldReconnect, error }) => {
856
+ state.manualOpen = false;
857
+ state.terminalError = shouldReconnect ? null : error;
858
+ },
859
+ noteParseError: (error) => {
860
+ state.terminalError = error;
861
+ state.manualOpen = false;
862
+ state.skipNextCloseReconnect = true;
863
+ state.reconnectSuppressed = true;
864
+ },
865
+ noteSocketOpened: () => {
866
+ state.manualClose = false;
867
+ state.manualOpen = false;
868
+ state.reconnectSuppressed = false;
869
+ state.terminalError = null;
870
+ },
871
+ noteUserCloseRequested: () => {
872
+ state.manualClose = true;
873
+ state.manualOpen = false;
874
+ state.reconnectSuppressed = true;
875
+ state.terminalError = null;
876
+ },
877
+ noteUserOpenRequested: () => {
878
+ state.manualClose = false;
879
+ state.manualOpen = true;
880
+ state.reconnectSuppressed = false;
881
+ state.terminalError = null;
882
+ },
883
+ noteUserReconnectClosed: () => {
884
+ state.reconnectSuppressed = false;
885
+ },
886
+ noteUserReconnectRequested: () => {
887
+ state.manualClose = false;
888
+ state.manualOpen = true;
889
+ state.skipNextCloseReconnect = true;
890
+ state.reconnectSuppressed = true;
891
+ state.terminalError = null;
892
+ },
893
+ peekTerminalError: () => state.terminalError
894
+ };
895
+ }
896
+ return controllerRef.current;
897
+ };
808
898
 
809
899
  // src/hooks/useWebSocket.ts
810
- var createInitialState3 = (status = "idle") => ({
900
+ var createInitialState4 = (status = "idle") => ({
811
901
  bufferedAmount: 0,
812
902
  lastChangedAt: null,
813
903
  lastCloseEvent: null,
@@ -850,18 +940,15 @@ var useWebSocket = (options) => {
850
940
  const activeSocketEpochRef = react.useRef(null);
851
941
  const closingSocketEpochRef = react.useRef(null);
852
942
  const nextSocketEpochRef = react.useRef(0);
853
- const manualCloseRef = react.useRef(false);
854
- const manualOpenRef = react.useRef(false);
855
- const skipCloseReconnectRef = react.useRef(false);
856
- const suppressReconnectRef = react.useRef(false);
857
- const pendingCloseActionRef = react.useRef(null);
858
- const terminalErrorRef = react.useRef(null);
943
+ const controller = useWebSocketController();
859
944
  const [openNonce, setOpenNonce] = react.useState(0);
860
945
  const [state, setState] = react.useState(
861
- () => createInitialState3(connect ? "connecting" : "idle")
946
+ () => createInitialState4(connect ? "connecting" : "idle")
862
947
  );
863
948
  const stateRef = react.useRef(state);
864
- stateRef.current = state;
949
+ react.useInsertionEffect(() => {
950
+ stateRef.current = state;
951
+ });
865
952
  const reconnectEnabled = options.reconnect !== false && supported && resolvedUrl !== null;
866
953
  const reconnect = useReconnect(
867
954
  options.reconnect === false ? { enabled: false } : {
@@ -978,10 +1065,9 @@ var useWebSocket = (options) => {
978
1065
  return;
979
1066
  }
980
1067
  const shouldReconnect = action === "reconnect" && reconnectEnabled && (options.shouldReconnect?.(error) ?? true);
981
- manualOpenRef.current = false;
982
- terminalErrorRef.current = shouldReconnect ? null : error;
983
1068
  const socket2 = socketRef.current;
984
1069
  if (socket2 === null || !isSocketActive(socket2)) {
1070
+ controller.noteHeartbeatNoActiveSocket({ error, shouldReconnect });
985
1071
  commitState((current) => ({
986
1072
  ...current,
987
1073
  lastChangedAt: Date.now(),
@@ -993,12 +1079,10 @@ var useWebSocket = (options) => {
993
1079
  }
994
1080
  return;
995
1081
  }
996
- pendingCloseActionRef.current = {
1082
+ controller.noteHeartbeatActiveSocketClose({
997
1083
  error,
998
1084
  reconnectTrigger: shouldReconnect ? reconnectTrigger : null
999
- };
1000
- skipCloseReconnectRef.current = true;
1001
- suppressReconnectRef.current = true;
1085
+ });
1002
1086
  closeSocket({ trackClose: true });
1003
1087
  }
1004
1088
  );
@@ -1013,10 +1097,7 @@ var useWebSocket = (options) => {
1013
1097
  }));
1014
1098
  });
1015
1099
  const handleOpen = useStableCallback((event, socket2) => {
1016
- manualCloseRef.current = false;
1017
- manualOpenRef.current = false;
1018
- suppressReconnectRef.current = false;
1019
- terminalErrorRef.current = null;
1100
+ controller.noteSocketOpened();
1020
1101
  reconnect.markConnected();
1021
1102
  heartbeat.start();
1022
1103
  commitState((current) => ({
@@ -1043,10 +1124,7 @@ var useWebSocket = (options) => {
1043
1124
  cause: error,
1044
1125
  kind: "parse-error"
1045
1126
  });
1046
- terminalErrorRef.current = parseError;
1047
- manualOpenRef.current = false;
1048
- skipCloseReconnectRef.current = true;
1049
- suppressReconnectRef.current = true;
1127
+ controller.noteParseError(parseError);
1050
1128
  reconnect.cancel();
1051
1129
  heartbeat.stop();
1052
1130
  options.onError?.(parseError);
@@ -1090,13 +1168,11 @@ var useWebSocket = (options) => {
1090
1168
  }
1091
1169
  heartbeat.stop();
1092
1170
  updateBufferedAmount();
1093
- const pendingCloseAction = pendingCloseActionRef.current;
1094
- pendingCloseActionRef.current = null;
1095
- const terminalError = terminalErrorRef.current;
1096
- const skipCloseReconnect = skipCloseReconnectRef.current;
1097
- skipCloseReconnectRef.current = false;
1171
+ const pendingCloseAction = controller.consumePendingCloseAction();
1172
+ const terminalError = controller.peekTerminalError();
1173
+ const skipCloseReconnect = controller.consumeSkipNextCloseReconnect();
1098
1174
  if (pendingCloseAction !== null) {
1099
- suppressReconnectRef.current = false;
1175
+ controller.clearReconnectSuppression();
1100
1176
  commitState((current) => ({
1101
1177
  ...current,
1102
1178
  lastChangedAt: Date.now(),
@@ -1111,7 +1187,7 @@ var useWebSocket = (options) => {
1111
1187
  return;
1112
1188
  }
1113
1189
  if (terminalError !== null) {
1114
- suppressReconnectRef.current = false;
1190
+ controller.clearReconnectSuppression();
1115
1191
  commitState((current) => ({
1116
1192
  ...current,
1117
1193
  lastChangedAt: Date.now(),
@@ -1122,7 +1198,7 @@ var useWebSocket = (options) => {
1122
1198
  options.onClose?.(event);
1123
1199
  return;
1124
1200
  }
1125
- const shouldReconnect = !suppressReconnectRef.current && !skipCloseReconnect && reconnectEnabled && (options.shouldReconnect?.(event) ?? true);
1201
+ const shouldReconnect = !controller.isReconnectSuppressed() && !skipCloseReconnect && reconnectEnabled && (options.shouldReconnect?.(event) ?? true);
1126
1202
  commitState((current) => ({
1127
1203
  ...current,
1128
1204
  lastChangedAt: Date.now(),
@@ -1133,33 +1209,23 @@ var useWebSocket = (options) => {
1133
1209
  if (shouldReconnect) {
1134
1210
  reconnect.schedule("close");
1135
1211
  } else {
1136
- suppressReconnectRef.current = false;
1212
+ controller.clearReconnectSuppression();
1137
1213
  }
1138
1214
  });
1139
1215
  const open = useStableCallback(() => {
1140
- manualCloseRef.current = false;
1141
- manualOpenRef.current = true;
1142
- suppressReconnectRef.current = false;
1143
- terminalErrorRef.current = null;
1216
+ controller.noteUserOpenRequested();
1144
1217
  reconnect.cancel();
1145
1218
  setOpenNonce((current) => current + 1);
1146
1219
  });
1147
1220
  const reconnectNow = useStableCallback(() => {
1148
- manualCloseRef.current = false;
1149
- manualOpenRef.current = true;
1150
- skipCloseReconnectRef.current = true;
1151
- suppressReconnectRef.current = true;
1152
- terminalErrorRef.current = null;
1221
+ controller.noteUserReconnectRequested();
1153
1222
  heartbeat.stop();
1154
1223
  closeSocket();
1155
- suppressReconnectRef.current = false;
1224
+ controller.noteUserReconnectClosed();
1156
1225
  reconnect.schedule("manual");
1157
1226
  });
1158
1227
  const close = useStableCallback((code, reason) => {
1159
- manualCloseRef.current = true;
1160
- manualOpenRef.current = false;
1161
- suppressReconnectRef.current = true;
1162
- terminalErrorRef.current = null;
1228
+ controller.noteUserCloseRequested();
1163
1229
  reconnect.cancel();
1164
1230
  heartbeat.stop();
1165
1231
  commitState((current) => ({
@@ -1201,22 +1267,22 @@ var useWebSocket = (options) => {
1201
1267
  }));
1202
1268
  return;
1203
1269
  }
1204
- const shouldConnect = terminalErrorRef.current === null && (connect && !manualCloseRef.current || manualOpenRef.current || reconnect.status === "running");
1270
+ const shouldConnect = controller.peekTerminalError() === null && (connect && !controller.hasManualCloseRequested() || controller.hasManualOpenRequested() || reconnect.status === "running");
1205
1271
  const nextSocketKey = `${resolvedUrl}::${protocolsDependency}::${options.binaryType ?? "blob"}`;
1206
1272
  if (!shouldConnect) {
1207
1273
  if (socketRef.current !== null) {
1208
- suppressReconnectRef.current = true;
1274
+ controller.noteEffectInitiatedClose();
1209
1275
  closeSocket();
1210
1276
  }
1211
1277
  socketKeyRef.current = null;
1212
1278
  commitState((current) => ({
1213
1279
  ...current,
1214
- status: terminalErrorRef.current !== null ? "error" : manualCloseRef.current ? "closed" : "idle"
1280
+ status: controller.peekTerminalError() !== null ? "error" : controller.hasManualCloseRequested() ? "closed" : "idle"
1215
1281
  }));
1216
1282
  return;
1217
1283
  }
1218
1284
  if (socketRef.current !== null && socketKeyRef.current !== nextSocketKey) {
1219
- suppressReconnectRef.current = true;
1285
+ controller.noteEffectInitiatedClose();
1220
1286
  closeSocket();
1221
1287
  }
1222
1288
  if (socketRef.current !== null) {
@@ -1283,6 +1349,7 @@ var useWebSocket = (options) => {
1283
1349
  closeSocket,
1284
1350
  commitState,
1285
1351
  connect,
1352
+ controller,
1286
1353
  handleClose,
1287
1354
  handleError,
1288
1355
  handleMessage,
@@ -1297,11 +1364,10 @@ var useWebSocket = (options) => {
1297
1364
  supported
1298
1365
  ]);
1299
1366
  react.useEffect(() => () => {
1300
- suppressReconnectRef.current = true;
1367
+ controller.noteEffectInitiatedClose();
1301
1368
  socketKeyRef.current = null;
1302
1369
  activeSocketEpochRef.current = null;
1303
1370
  closingSocketEpochRef.current = null;
1304
- terminalErrorRef.current = null;
1305
1371
  const socket2 = socketRef.current;
1306
1372
  socketRef.current = null;
1307
1373
  if (socket2 === null) {
@@ -1310,7 +1376,7 @@ var useWebSocket = (options) => {
1310
1376
  if (isSocketActive(socket2)) {
1311
1377
  socket2.close();
1312
1378
  }
1313
- }, []);
1379
+ }, [controller]);
1314
1380
  const stopHeartbeat = heartbeat.stop;
1315
1381
  react.useEffect(() => {
1316
1382
  if (state.status !== "open") {
@@ -1415,7 +1481,7 @@ var useWebSocket = (options) => {
1415
1481
  socket
1416
1482
  };
1417
1483
  };
1418
- var createInitialState4 = (status = "idle") => ({
1484
+ var createInitialState5 = (status = "idle") => ({
1419
1485
  lastChangedAt: null,
1420
1486
  lastError: null,
1421
1487
  lastEventName: null,
@@ -1452,10 +1518,12 @@ var useEventSource = (options) => {
1452
1518
  const terminalErrorRef = react.useRef(null);
1453
1519
  const [openNonce, setOpenNonce] = react.useState(0);
1454
1520
  const [state, setState] = react.useState(
1455
- () => createInitialState4(connect ? "connecting" : "idle")
1521
+ () => createInitialState5(connect ? "connecting" : "idle")
1456
1522
  );
1457
1523
  const stateRef = react.useRef(state);
1458
- stateRef.current = state;
1524
+ react.useInsertionEffect(() => {
1525
+ stateRef.current = state;
1526
+ });
1459
1527
  const reconnectEnabled = options.reconnect !== false && supported && resolvedUrl !== null;
1460
1528
  const reconnect = useReconnect(
1461
1529
  options.reconnect === false ? { enabled: false } : {