react-realtime-hooks 1.3.4 → 1.4.1

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
@@ -257,6 +257,21 @@ export function ChatSocket() {
257
257
  }
258
258
  ```
259
259
 
260
+ The native `WebSocket` does not emit any event when buffered bytes drain to the network, so by default `bufferedAmount` only refreshes on `send()`, on incoming messages, and on the `open` transition. Pass `bufferedAmountPolling` to surface the live value for backpressure indicators:
261
+
262
+ ```tsx
263
+ const socket = useWebSocket<IncomingMessage, OutgoingMessage>({
264
+ url: "ws://localhost:8080",
265
+ // "raf" reads `bufferedAmount` on every animation frame while open;
266
+ // use `true` for a 100ms interval, or `{ intervalMs: 50 }` for custom.
267
+ bufferedAmountPolling: "raf",
268
+ });
269
+
270
+ // `socket.bufferedAmount` now ticks down as the OS flushes the buffer,
271
+ // even between `send` calls. Identical readings do not trigger React
272
+ // re-renders.
273
+ ```
274
+
260
275
  ### `useEventSource`
261
276
 
262
277
  ```tsx
@@ -400,6 +415,7 @@ export function GatedNotifications() {
400
415
  | `protocols` | `string \| string[]` | `undefined` | WebSocket subprotocols |
401
416
  | `connect` | `boolean` | `true` | Auto-connect on mount |
402
417
  | `binaryType` | `BinaryType` | `"blob"` | Socket binary mode |
418
+ | `bufferedAmountPolling` | `false \| true \| "raf" \| { intervalMs: number }` | `false` | Live polling of `WebSocket.bufferedAmount` for backpressure UIs |
403
419
  | `parseMessage` | `(event) => TIncoming` | raw `event.data` | Incoming parser |
404
420
  | `serializeMessage` | `(message) => ...` | JSON/string passthrough | Outgoing serializer |
405
421
  | `reconnect` | `false \| UseReconnectOptions` | enabled | Reconnect configuration |
@@ -420,7 +436,7 @@ export function GatedNotifications() {
420
436
  | `lastMessageEvent` | `MessageEvent \| null` | Last raw message event |
421
437
  | `lastCloseEvent` | `CloseEvent \| null` | Last close event |
422
438
  | `lastError` | `Event \| null` | Last transport error, or `RealtimeErrorEvent` for parse/heartbeat failures |
423
- | `bufferedAmount` | `number` | Current socket buffer size |
439
+ | `bufferedAmount` | `number` | Current socket buffer size (refresh cadence is controlled by `bufferedAmountPolling`) |
424
440
  | `reconnectState` | reconnect snapshot or `null` | Current reconnect data |
425
441
  | `heartbeatState` | heartbeat snapshot or `null` | Current heartbeat data |
426
442
  | `open` | `() => void` | Manual connect |
package/dist/index.cjs CHANGED
@@ -805,9 +805,95 @@ var resolveUrlProvider = (url) => {
805
805
  const resolved = typeof url === "function" ? url() : url;
806
806
  return normalizeResolvedUrl(resolved ?? null);
807
807
  };
808
+ var createInitialState3 = () => ({
809
+ manualClose: false,
810
+ manualOpen: false,
811
+ pendingCloseAction: null,
812
+ reconnectSuppressed: false,
813
+ skipNextCloseReconnect: false,
814
+ terminalError: null
815
+ });
816
+ var useWebSocketController = () => {
817
+ const ref = react.useRef(null);
818
+ if (ref.current === null) {
819
+ ref.current = createInitialState3();
820
+ }
821
+ const state = ref.current;
822
+ const controllerRef = react.useRef(null);
823
+ if (controllerRef.current === null) {
824
+ controllerRef.current = {
825
+ clearReconnectSuppression: () => {
826
+ state.reconnectSuppressed = false;
827
+ },
828
+ consumePendingCloseAction: () => {
829
+ const action = state.pendingCloseAction;
830
+ state.pendingCloseAction = null;
831
+ return action;
832
+ },
833
+ consumeSkipNextCloseReconnect: () => {
834
+ const skip = state.skipNextCloseReconnect;
835
+ state.skipNextCloseReconnect = false;
836
+ return skip;
837
+ },
838
+ hasManualCloseRequested: () => state.manualClose,
839
+ hasManualOpenRequested: () => state.manualOpen,
840
+ isReconnectSuppressed: () => state.reconnectSuppressed,
841
+ noteEffectInitiatedClose: () => {
842
+ state.reconnectSuppressed = true;
843
+ },
844
+ noteHeartbeatActiveSocketClose: (input) => {
845
+ state.manualOpen = false;
846
+ state.terminalError = input.reconnectTrigger === null ? input.error : null;
847
+ state.pendingCloseAction = input;
848
+ state.skipNextCloseReconnect = true;
849
+ state.reconnectSuppressed = true;
850
+ },
851
+ noteHeartbeatNoActiveSocket: ({ shouldReconnect, error }) => {
852
+ state.manualOpen = false;
853
+ state.terminalError = shouldReconnect ? null : error;
854
+ },
855
+ noteParseError: (error) => {
856
+ state.terminalError = error;
857
+ state.manualOpen = false;
858
+ state.skipNextCloseReconnect = true;
859
+ state.reconnectSuppressed = true;
860
+ },
861
+ noteSocketOpened: () => {
862
+ state.manualClose = false;
863
+ state.manualOpen = false;
864
+ state.reconnectSuppressed = false;
865
+ state.terminalError = null;
866
+ },
867
+ noteUserCloseRequested: () => {
868
+ state.manualClose = true;
869
+ state.manualOpen = false;
870
+ state.reconnectSuppressed = true;
871
+ state.terminalError = null;
872
+ },
873
+ noteUserOpenRequested: () => {
874
+ state.manualClose = false;
875
+ state.manualOpen = true;
876
+ state.reconnectSuppressed = false;
877
+ state.terminalError = null;
878
+ },
879
+ noteUserReconnectClosed: () => {
880
+ state.reconnectSuppressed = false;
881
+ },
882
+ noteUserReconnectRequested: () => {
883
+ state.manualClose = false;
884
+ state.manualOpen = true;
885
+ state.skipNextCloseReconnect = true;
886
+ state.reconnectSuppressed = true;
887
+ state.terminalError = null;
888
+ },
889
+ peekTerminalError: () => state.terminalError
890
+ };
891
+ }
892
+ return controllerRef.current;
893
+ };
808
894
 
809
895
  // src/hooks/useWebSocket.ts
810
- var createInitialState3 = (status = "idle") => ({
896
+ var createInitialState4 = (status = "idle") => ({
811
897
  bufferedAmount: 0,
812
898
  lastChangedAt: null,
813
899
  lastCloseEvent: null,
@@ -850,15 +936,10 @@ var useWebSocket = (options) => {
850
936
  const activeSocketEpochRef = react.useRef(null);
851
937
  const closingSocketEpochRef = react.useRef(null);
852
938
  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);
939
+ const controller = useWebSocketController();
859
940
  const [openNonce, setOpenNonce] = react.useState(0);
860
941
  const [state, setState] = react.useState(
861
- () => createInitialState3(connect ? "connecting" : "idle")
942
+ () => createInitialState4(connect ? "connecting" : "idle")
862
943
  );
863
944
  const stateRef = react.useRef(state);
864
945
  stateRef.current = state;
@@ -978,10 +1059,9 @@ var useWebSocket = (options) => {
978
1059
  return;
979
1060
  }
980
1061
  const shouldReconnect = action === "reconnect" && reconnectEnabled && (options.shouldReconnect?.(error) ?? true);
981
- manualOpenRef.current = false;
982
- terminalErrorRef.current = shouldReconnect ? null : error;
983
1062
  const socket2 = socketRef.current;
984
1063
  if (socket2 === null || !isSocketActive(socket2)) {
1064
+ controller.noteHeartbeatNoActiveSocket({ error, shouldReconnect });
985
1065
  commitState((current) => ({
986
1066
  ...current,
987
1067
  lastChangedAt: Date.now(),
@@ -993,12 +1073,10 @@ var useWebSocket = (options) => {
993
1073
  }
994
1074
  return;
995
1075
  }
996
- pendingCloseActionRef.current = {
1076
+ controller.noteHeartbeatActiveSocketClose({
997
1077
  error,
998
1078
  reconnectTrigger: shouldReconnect ? reconnectTrigger : null
999
- };
1000
- skipCloseReconnectRef.current = true;
1001
- suppressReconnectRef.current = true;
1079
+ });
1002
1080
  closeSocket({ trackClose: true });
1003
1081
  }
1004
1082
  );
@@ -1013,10 +1091,7 @@ var useWebSocket = (options) => {
1013
1091
  }));
1014
1092
  });
1015
1093
  const handleOpen = useStableCallback((event, socket2) => {
1016
- manualCloseRef.current = false;
1017
- manualOpenRef.current = false;
1018
- suppressReconnectRef.current = false;
1019
- terminalErrorRef.current = null;
1094
+ controller.noteSocketOpened();
1020
1095
  reconnect.markConnected();
1021
1096
  heartbeat.start();
1022
1097
  commitState((current) => ({
@@ -1043,10 +1118,7 @@ var useWebSocket = (options) => {
1043
1118
  cause: error,
1044
1119
  kind: "parse-error"
1045
1120
  });
1046
- terminalErrorRef.current = parseError;
1047
- manualOpenRef.current = false;
1048
- skipCloseReconnectRef.current = true;
1049
- suppressReconnectRef.current = true;
1121
+ controller.noteParseError(parseError);
1050
1122
  reconnect.cancel();
1051
1123
  heartbeat.stop();
1052
1124
  options.onError?.(parseError);
@@ -1090,13 +1162,11 @@ var useWebSocket = (options) => {
1090
1162
  }
1091
1163
  heartbeat.stop();
1092
1164
  updateBufferedAmount();
1093
- const pendingCloseAction = pendingCloseActionRef.current;
1094
- pendingCloseActionRef.current = null;
1095
- const terminalError = terminalErrorRef.current;
1096
- const skipCloseReconnect = skipCloseReconnectRef.current;
1097
- skipCloseReconnectRef.current = false;
1165
+ const pendingCloseAction = controller.consumePendingCloseAction();
1166
+ const terminalError = controller.peekTerminalError();
1167
+ const skipCloseReconnect = controller.consumeSkipNextCloseReconnect();
1098
1168
  if (pendingCloseAction !== null) {
1099
- suppressReconnectRef.current = false;
1169
+ controller.clearReconnectSuppression();
1100
1170
  commitState((current) => ({
1101
1171
  ...current,
1102
1172
  lastChangedAt: Date.now(),
@@ -1111,7 +1181,7 @@ var useWebSocket = (options) => {
1111
1181
  return;
1112
1182
  }
1113
1183
  if (terminalError !== null) {
1114
- suppressReconnectRef.current = false;
1184
+ controller.clearReconnectSuppression();
1115
1185
  commitState((current) => ({
1116
1186
  ...current,
1117
1187
  lastChangedAt: Date.now(),
@@ -1122,7 +1192,7 @@ var useWebSocket = (options) => {
1122
1192
  options.onClose?.(event);
1123
1193
  return;
1124
1194
  }
1125
- const shouldReconnect = !suppressReconnectRef.current && !skipCloseReconnect && reconnectEnabled && (options.shouldReconnect?.(event) ?? true);
1195
+ const shouldReconnect = !controller.isReconnectSuppressed() && !skipCloseReconnect && reconnectEnabled && (options.shouldReconnect?.(event) ?? true);
1126
1196
  commitState((current) => ({
1127
1197
  ...current,
1128
1198
  lastChangedAt: Date.now(),
@@ -1133,33 +1203,23 @@ var useWebSocket = (options) => {
1133
1203
  if (shouldReconnect) {
1134
1204
  reconnect.schedule("close");
1135
1205
  } else {
1136
- suppressReconnectRef.current = false;
1206
+ controller.clearReconnectSuppression();
1137
1207
  }
1138
1208
  });
1139
1209
  const open = useStableCallback(() => {
1140
- manualCloseRef.current = false;
1141
- manualOpenRef.current = true;
1142
- suppressReconnectRef.current = false;
1143
- terminalErrorRef.current = null;
1210
+ controller.noteUserOpenRequested();
1144
1211
  reconnect.cancel();
1145
1212
  setOpenNonce((current) => current + 1);
1146
1213
  });
1147
1214
  const reconnectNow = useStableCallback(() => {
1148
- manualCloseRef.current = false;
1149
- manualOpenRef.current = true;
1150
- skipCloseReconnectRef.current = true;
1151
- suppressReconnectRef.current = true;
1152
- terminalErrorRef.current = null;
1215
+ controller.noteUserReconnectRequested();
1153
1216
  heartbeat.stop();
1154
1217
  closeSocket();
1155
- suppressReconnectRef.current = false;
1218
+ controller.noteUserReconnectClosed();
1156
1219
  reconnect.schedule("manual");
1157
1220
  });
1158
1221
  const close = useStableCallback((code, reason) => {
1159
- manualCloseRef.current = true;
1160
- manualOpenRef.current = false;
1161
- suppressReconnectRef.current = true;
1162
- terminalErrorRef.current = null;
1222
+ controller.noteUserCloseRequested();
1163
1223
  reconnect.cancel();
1164
1224
  heartbeat.stop();
1165
1225
  commitState((current) => ({
@@ -1201,22 +1261,22 @@ var useWebSocket = (options) => {
1201
1261
  }));
1202
1262
  return;
1203
1263
  }
1204
- const shouldConnect = terminalErrorRef.current === null && (connect && !manualCloseRef.current || manualOpenRef.current || reconnect.status === "running");
1264
+ const shouldConnect = controller.peekTerminalError() === null && (connect && !controller.hasManualCloseRequested() || controller.hasManualOpenRequested() || reconnect.status === "running");
1205
1265
  const nextSocketKey = `${resolvedUrl}::${protocolsDependency}::${options.binaryType ?? "blob"}`;
1206
1266
  if (!shouldConnect) {
1207
1267
  if (socketRef.current !== null) {
1208
- suppressReconnectRef.current = true;
1268
+ controller.noteEffectInitiatedClose();
1209
1269
  closeSocket();
1210
1270
  }
1211
1271
  socketKeyRef.current = null;
1212
1272
  commitState((current) => ({
1213
1273
  ...current,
1214
- status: terminalErrorRef.current !== null ? "error" : manualCloseRef.current ? "closed" : "idle"
1274
+ status: controller.peekTerminalError() !== null ? "error" : controller.hasManualCloseRequested() ? "closed" : "idle"
1215
1275
  }));
1216
1276
  return;
1217
1277
  }
1218
1278
  if (socketRef.current !== null && socketKeyRef.current !== nextSocketKey) {
1219
- suppressReconnectRef.current = true;
1279
+ controller.noteEffectInitiatedClose();
1220
1280
  closeSocket();
1221
1281
  }
1222
1282
  if (socketRef.current !== null) {
@@ -1283,6 +1343,7 @@ var useWebSocket = (options) => {
1283
1343
  closeSocket,
1284
1344
  commitState,
1285
1345
  connect,
1346
+ controller,
1286
1347
  handleClose,
1287
1348
  handleError,
1288
1349
  handleMessage,
@@ -1297,11 +1358,10 @@ var useWebSocket = (options) => {
1297
1358
  supported
1298
1359
  ]);
1299
1360
  react.useEffect(() => () => {
1300
- suppressReconnectRef.current = true;
1361
+ controller.noteEffectInitiatedClose();
1301
1362
  socketKeyRef.current = null;
1302
1363
  activeSocketEpochRef.current = null;
1303
1364
  closingSocketEpochRef.current = null;
1304
- terminalErrorRef.current = null;
1305
1365
  const socket2 = socketRef.current;
1306
1366
  socketRef.current = null;
1307
1367
  if (socket2 === null) {
@@ -1310,13 +1370,65 @@ var useWebSocket = (options) => {
1310
1370
  if (isSocketActive(socket2)) {
1311
1371
  socket2.close();
1312
1372
  }
1313
- }, []);
1373
+ }, [controller]);
1314
1374
  const stopHeartbeat = heartbeat.stop;
1315
1375
  react.useEffect(() => {
1316
1376
  if (state.status !== "open") {
1317
1377
  stopHeartbeat();
1318
1378
  }
1319
1379
  }, [state.status, stopHeartbeat]);
1380
+ const rawBufferedAmountPolling = options.bufferedAmountPolling;
1381
+ const bufferedAmountPollingMode = react.useMemo(() => {
1382
+ if (rawBufferedAmountPolling === void 0 || rawBufferedAmountPolling === false) {
1383
+ return null;
1384
+ }
1385
+ if (rawBufferedAmountPolling === true) {
1386
+ return 100;
1387
+ }
1388
+ if (rawBufferedAmountPolling === "raf") {
1389
+ return "raf";
1390
+ }
1391
+ const intervalMs = rawBufferedAmountPolling.intervalMs;
1392
+ return Number.isFinite(intervalMs) && intervalMs > 0 ? intervalMs : null;
1393
+ }, [rawBufferedAmountPolling]);
1394
+ const pollBufferedAmount = useStableCallback(() => {
1395
+ const socket2 = socketRef.current;
1396
+ if (socket2 === null) {
1397
+ return;
1398
+ }
1399
+ const next = socket2.bufferedAmount;
1400
+ if (stateRef.current.bufferedAmount === next) {
1401
+ return;
1402
+ }
1403
+ commitState((current) => ({
1404
+ ...current,
1405
+ bufferedAmount: next
1406
+ }));
1407
+ });
1408
+ react.useEffect(() => {
1409
+ if (bufferedAmountPollingMode === null) {
1410
+ return;
1411
+ }
1412
+ if (state.status !== "open") {
1413
+ return;
1414
+ }
1415
+ if (bufferedAmountPollingMode === "raf") {
1416
+ if (typeof requestAnimationFrame !== "function") {
1417
+ return;
1418
+ }
1419
+ let frame = requestAnimationFrame(function loop() {
1420
+ pollBufferedAmount();
1421
+ frame = requestAnimationFrame(loop);
1422
+ });
1423
+ return () => {
1424
+ cancelAnimationFrame(frame);
1425
+ };
1426
+ }
1427
+ const intervalId = setInterval(pollBufferedAmount, bufferedAmountPollingMode);
1428
+ return () => {
1429
+ clearInterval(intervalId);
1430
+ };
1431
+ }, [bufferedAmountPollingMode, state.status, pollBufferedAmount]);
1320
1432
  const status = (reconnect.status === "scheduled" || reconnect.status === "running") && state.status !== "open" ? "reconnecting" : state.status;
1321
1433
  const snapshot = createConnectionStateSnapshot(status, {
1322
1434
  isSupported: supported,
@@ -1363,7 +1475,7 @@ var useWebSocket = (options) => {
1363
1475
  socket
1364
1476
  };
1365
1477
  };
1366
- var createInitialState4 = (status = "idle") => ({
1478
+ var createInitialState5 = (status = "idle") => ({
1367
1479
  lastChangedAt: null,
1368
1480
  lastError: null,
1369
1481
  lastEventName: null,
@@ -1400,7 +1512,7 @@ var useEventSource = (options) => {
1400
1512
  const terminalErrorRef = react.useRef(null);
1401
1513
  const [openNonce, setOpenNonce] = react.useState(0);
1402
1514
  const [state, setState] = react.useState(
1403
- () => createInitialState4(connect ? "connecting" : "idle")
1515
+ () => createInitialState5(connect ? "connecting" : "idle")
1404
1516
  );
1405
1517
  const stateRef = react.useRef(state);
1406
1518
  stateRef.current = state;