react-realtime-hooks 1.1.0 → 1.2.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/dist/index.d.ts CHANGED
@@ -29,6 +29,34 @@ type UsePageVisibilityHook = (options?: UsePageVisibilityOptions) => UsePageVisi
29
29
 
30
30
  declare const usePageVisibility: UsePageVisibilityHook;
31
31
 
32
+ type ConnectionGateReason = "ready" | "manual" | "offline" | "hidden";
33
+ interface UseConnectionGateOptions {
34
+ enabled?: boolean;
35
+ requireOnline?: boolean;
36
+ requireVisible?: boolean;
37
+ hiddenGraceMs?: number;
38
+ initialOnline?: boolean;
39
+ initialVisible?: boolean;
40
+ trackTransitions?: boolean;
41
+ }
42
+ interface UseConnectionGateResult {
43
+ connect: boolean;
44
+ isBlocked: boolean;
45
+ isWaitingForVisibleGrace: boolean;
46
+ reason: ConnectionGateReason;
47
+ isOnline: boolean;
48
+ isOnlineSupported: boolean;
49
+ isVisible: boolean;
50
+ isVisibilitySupported: boolean;
51
+ visibilityState: DocumentVisibilityState | "visible";
52
+ lastChangedAt: number | null;
53
+ becameReadyAt: number | null;
54
+ becameBlockedAt: number | null;
55
+ }
56
+ type UseConnectionGateHook = (options?: UseConnectionGateOptions) => UseConnectionGateResult;
57
+
58
+ declare const useConnectionGate: UseConnectionGateHook;
59
+
32
60
  type ReconnectStatus = "idle" | "scheduled" | "running" | "stopped";
33
61
  type ReconnectTrigger = "mount" | "manual" | "close" | "error" | "heartbeat-timeout" | "offline" | "online" | "visibility";
34
62
  interface ReconnectAttempt {
@@ -235,4 +263,4 @@ type UseEventSourceHook = <TMessage = unknown>(options: UseEventSourceOptions<TM
235
263
 
236
264
  declare const useEventSource: UseEventSourceHook;
237
265
 
238
- export { type ConnectionStateSnapshot, type HeartbeatAckMatcher, type HeartbeatBeatFn, type MessageParser, type MessageSerializer, type Milliseconds, type RealtimeConnectionStatus, type RealtimeTransport, type ReconnectAttempt, type ReconnectDelayContext, type ReconnectDelayStrategy, type ReconnectStatus, type ReconnectTrigger, type UrlProvider, type UseEventSourceHook, type UseEventSourceOptions, type UseEventSourceResult, type UseHeartbeatHook, type UseHeartbeatOptions, type UseHeartbeatResult, type UseOnlineStatusHook, type UseOnlineStatusOptions, type UseOnlineStatusResult, type UsePageVisibilityHook, type UsePageVisibilityOptions, type UsePageVisibilityResult, type UseReconnectHook, type UseReconnectOptions, type UseReconnectResult, type UseWebSocketHeartbeatOptions, type UseWebSocketHook, type UseWebSocketOptions, type UseWebSocketResult, type WebSocketHeartbeatAction, useEventSource, useHeartbeat, useOnlineStatus, usePageVisibility, useReconnect, useWebSocket };
266
+ export { type ConnectionGateReason, type ConnectionStateSnapshot, type HeartbeatAckMatcher, type HeartbeatBeatFn, type MessageParser, type MessageSerializer, type Milliseconds, type RealtimeConnectionStatus, type RealtimeTransport, type ReconnectAttempt, type ReconnectDelayContext, type ReconnectDelayStrategy, type ReconnectStatus, type ReconnectTrigger, type UrlProvider, type UseConnectionGateHook, type UseConnectionGateOptions, type UseConnectionGateResult, type UseEventSourceHook, type UseEventSourceOptions, type UseEventSourceResult, type UseHeartbeatHook, type UseHeartbeatOptions, type UseHeartbeatResult, type UseOnlineStatusHook, type UseOnlineStatusOptions, type UseOnlineStatusResult, type UsePageVisibilityHook, type UsePageVisibilityOptions, type UsePageVisibilityResult, type UseReconnectHook, type UseReconnectOptions, type UseReconnectResult, type UseWebSocketHeartbeatOptions, type UseWebSocketHook, type UseWebSocketOptions, type UseWebSocketResult, type WebSocketHeartbeatAction, useConnectionGate, useEventSource, useHeartbeat, useOnlineStatus, usePageVisibility, useReconnect, useWebSocket };
package/dist/index.js CHANGED
@@ -136,6 +136,177 @@ var usePageVisibility = (options = {}) => {
136
136
  };
137
137
  };
138
138
 
139
+ // src/core/timers.ts
140
+ var sanitizeTimerDelay = (delayMs) => {
141
+ if (!Number.isFinite(delayMs)) {
142
+ return 0;
143
+ }
144
+ return Math.max(0, Math.round(delayMs));
145
+ };
146
+ var createManagedTimeout = () => {
147
+ let timeoutId = null;
148
+ return {
149
+ cancel() {
150
+ if (timeoutId !== null) {
151
+ clearTimeout(timeoutId);
152
+ timeoutId = null;
153
+ }
154
+ },
155
+ isActive() {
156
+ return timeoutId !== null;
157
+ },
158
+ schedule(callback, delayMs) {
159
+ if (timeoutId !== null) {
160
+ clearTimeout(timeoutId);
161
+ }
162
+ timeoutId = setTimeout(() => {
163
+ timeoutId = null;
164
+ callback();
165
+ }, sanitizeTimerDelay(delayMs));
166
+ }
167
+ };
168
+ };
169
+ var createManagedInterval = () => {
170
+ let intervalId = null;
171
+ return {
172
+ cancel() {
173
+ if (intervalId !== null) {
174
+ clearInterval(intervalId);
175
+ intervalId = null;
176
+ }
177
+ },
178
+ isActive() {
179
+ return intervalId !== null;
180
+ },
181
+ start(callback, intervalMs) {
182
+ if (intervalId !== null) {
183
+ clearInterval(intervalId);
184
+ }
185
+ intervalId = setInterval(callback, sanitizeTimerDelay(intervalMs));
186
+ }
187
+ };
188
+ };
189
+
190
+ // src/hooks/useConnectionGate.ts
191
+ var createEmptyTransitionState3 = () => ({
192
+ becameBlockedAt: null,
193
+ becameReadyAt: null,
194
+ lastChangedAt: null
195
+ });
196
+ var normalizeHiddenGraceMs = (value) => {
197
+ if (value === void 0 || !Number.isFinite(value)) {
198
+ return 0;
199
+ }
200
+ return Math.max(0, value);
201
+ };
202
+ var useConnectionGate = (options = {}) => {
203
+ const enabled = options.enabled ?? true;
204
+ const requireOnline = options.requireOnline ?? true;
205
+ const requireVisible = options.requireVisible ?? false;
206
+ const hiddenGraceMs = normalizeHiddenGraceMs(options.hiddenGraceMs);
207
+ const trackTransitions = options.trackTransitions ?? true;
208
+ const onlineStatus = useOnlineStatus({
209
+ ...options.initialOnline === void 0 ? {} : { initialOnline: options.initialOnline },
210
+ trackTransitions: false
211
+ });
212
+ const pageVisibility = usePageVisibility({
213
+ ...options.initialVisible === void 0 ? {} : { initialVisible: options.initialVisible },
214
+ trackTransitions: false
215
+ });
216
+ const hiddenGraceTimeoutRef = useRef(createManagedTimeout());
217
+ const hiddenSinceRef = useRef(null);
218
+ const previousStateRef = useRef(null);
219
+ const [hasExceededHiddenGrace, setHasExceededHiddenGrace] = useState(false);
220
+ const [isWaitingForVisibleGrace, setIsWaitingForVisibleGrace] = useState(false);
221
+ const [transitions, setTransitions] = useState(createEmptyTransitionState3);
222
+ useEffect(() => () => {
223
+ hiddenGraceTimeoutRef.current.cancel();
224
+ }, []);
225
+ useEffect(() => {
226
+ hiddenGraceTimeoutRef.current.cancel();
227
+ if (!requireVisible || pageVisibility.isVisible) {
228
+ hiddenSinceRef.current = null;
229
+ setHasExceededHiddenGrace(false);
230
+ setIsWaitingForVisibleGrace(false);
231
+ return;
232
+ }
233
+ const hiddenSince = hiddenSinceRef.current ?? Date.now();
234
+ hiddenSinceRef.current = hiddenSince;
235
+ if (hiddenGraceMs <= 0) {
236
+ setHasExceededHiddenGrace(true);
237
+ setIsWaitingForVisibleGrace(false);
238
+ return;
239
+ }
240
+ const elapsedMs = Date.now() - hiddenSince;
241
+ if (elapsedMs >= hiddenGraceMs) {
242
+ setHasExceededHiddenGrace(true);
243
+ setIsWaitingForVisibleGrace(false);
244
+ return;
245
+ }
246
+ setHasExceededHiddenGrace(false);
247
+ setIsWaitingForVisibleGrace(true);
248
+ hiddenGraceTimeoutRef.current.schedule(() => {
249
+ setHasExceededHiddenGrace(true);
250
+ setIsWaitingForVisibleGrace(false);
251
+ }, hiddenGraceMs - elapsedMs);
252
+ }, [hiddenGraceMs, pageVisibility.isVisible, requireVisible]);
253
+ let reason = "ready";
254
+ if (!enabled) {
255
+ reason = "manual";
256
+ } else if (requireOnline && !onlineStatus.isOnline) {
257
+ reason = "offline";
258
+ } else if (requireVisible && !pageVisibility.isVisible && hasExceededHiddenGrace) {
259
+ reason = "hidden";
260
+ }
261
+ const connect = reason === "ready";
262
+ const isBlocked = !connect;
263
+ useEffect(() => {
264
+ if (!trackTransitions) {
265
+ previousStateRef.current = {
266
+ connect,
267
+ reason
268
+ };
269
+ setTransitions(createEmptyTransitionState3);
270
+ return;
271
+ }
272
+ const previousState = previousStateRef.current;
273
+ if (previousState === null) {
274
+ previousStateRef.current = {
275
+ connect,
276
+ reason
277
+ };
278
+ return;
279
+ }
280
+ if (previousState.connect === connect && previousState.reason === reason) {
281
+ return;
282
+ }
283
+ const changedAt = Date.now();
284
+ previousStateRef.current = {
285
+ connect,
286
+ reason
287
+ };
288
+ setTransitions((current) => ({
289
+ becameBlockedAt: connect ? current.becameBlockedAt : changedAt,
290
+ becameReadyAt: connect ? changedAt : current.becameReadyAt,
291
+ lastChangedAt: changedAt
292
+ }));
293
+ }, [connect, reason, trackTransitions]);
294
+ return {
295
+ becameBlockedAt: transitions.becameBlockedAt,
296
+ becameReadyAt: transitions.becameReadyAt,
297
+ connect,
298
+ isBlocked,
299
+ isOnline: onlineStatus.isOnline,
300
+ isOnlineSupported: onlineStatus.isSupported,
301
+ isVisibilitySupported: pageVisibility.isSupported,
302
+ isVisible: pageVisibility.isVisible,
303
+ isWaitingForVisibleGrace,
304
+ lastChangedAt: transitions.lastChangedAt,
305
+ reason,
306
+ visibilityState: pageVisibility.visibilityState
307
+ };
308
+ };
309
+
139
310
  // src/core/reconnect.ts
140
311
  var DEFAULT_RECONNECT_OPTIONS = {
141
312
  backoffFactor: 2,
@@ -272,57 +443,6 @@ var createReconnectAttempt = (attempt, trigger, options, lastDelayMs, config = {
272
443
  };
273
444
  };
274
445
 
275
- // src/core/timers.ts
276
- var sanitizeTimerDelay = (delayMs) => {
277
- if (!Number.isFinite(delayMs)) {
278
- return 0;
279
- }
280
- return Math.max(0, Math.round(delayMs));
281
- };
282
- var createManagedTimeout = () => {
283
- let timeoutId = null;
284
- return {
285
- cancel() {
286
- if (timeoutId !== null) {
287
- clearTimeout(timeoutId);
288
- timeoutId = null;
289
- }
290
- },
291
- isActive() {
292
- return timeoutId !== null;
293
- },
294
- schedule(callback, delayMs) {
295
- if (timeoutId !== null) {
296
- clearTimeout(timeoutId);
297
- }
298
- timeoutId = setTimeout(() => {
299
- timeoutId = null;
300
- callback();
301
- }, sanitizeTimerDelay(delayMs));
302
- }
303
- };
304
- };
305
- var createManagedInterval = () => {
306
- let intervalId = null;
307
- return {
308
- cancel() {
309
- if (intervalId !== null) {
310
- clearInterval(intervalId);
311
- intervalId = null;
312
- }
313
- },
314
- isActive() {
315
- return intervalId !== null;
316
- },
317
- start(callback, intervalMs) {
318
- if (intervalId !== null) {
319
- clearInterval(intervalId);
320
- }
321
- intervalId = setInterval(callback, sanitizeTimerDelay(intervalMs));
322
- }
323
- };
324
- };
325
-
326
446
  // src/hooks/useReconnect.ts
327
447
  var createInitialState = (enabled) => ({
328
448
  attempt: 0,
@@ -704,6 +824,9 @@ var useWebSocket = (options) => {
704
824
  const protocolsDependency = toProtocolsDependency(options.protocols);
705
825
  const socketRef = useRef(null);
706
826
  const socketKeyRef = useRef(null);
827
+ const activeSocketEpochRef = useRef(null);
828
+ const closingSocketEpochRef = useRef(null);
829
+ const nextSocketEpochRef = useRef(0);
707
830
  const manualCloseRef = useRef(false);
708
831
  const manualOpenRef = useRef(false);
709
832
  const skipCloseReconnectRef = useRef(false);
@@ -792,17 +915,28 @@ var useWebSocket = (options) => {
792
915
  stateRef.current = resolved;
793
916
  setState(resolved);
794
917
  };
795
- const closeSocket = useEffectEvent((code, reason) => {
796
- const socket2 = socketRef.current;
797
- if (socket2 === null) {
798
- return;
799
- }
800
- socketRef.current = null;
801
- socketKeyRef.current = null;
802
- if (isSocketActive(socket2)) {
803
- socket2.close(code, reason);
804
- }
918
+ const isActiveSocketEvent = useEffectEvent((socketEpoch) => {
919
+ return activeSocketEpochRef.current === socketEpoch;
805
920
  });
921
+ const shouldHandleSocketClose = useEffectEvent((socketEpoch) => {
922
+ return activeSocketEpochRef.current === socketEpoch || closingSocketEpochRef.current === socketEpoch;
923
+ });
924
+ const closeSocket = useEffectEvent(
925
+ (config = {}) => {
926
+ const socket2 = socketRef.current;
927
+ const socketEpoch = activeSocketEpochRef.current;
928
+ if (socket2 === null || socketEpoch === null) {
929
+ return;
930
+ }
931
+ socketRef.current = null;
932
+ socketKeyRef.current = null;
933
+ activeSocketEpochRef.current = null;
934
+ closingSocketEpochRef.current = config.trackClose ? socketEpoch : null;
935
+ if (isSocketActive(socket2)) {
936
+ socket2.close(config.code, config.reason);
937
+ }
938
+ }
939
+ );
806
940
  const applyHeartbeatAction = useEffectEvent(
807
941
  (action, error, reconnectTrigger) => {
808
942
  heartbeat.stop();
@@ -837,7 +971,7 @@ var useWebSocket = (options) => {
837
971
  };
838
972
  skipCloseReconnectRef.current = true;
839
973
  suppressReconnectRef.current = true;
840
- closeSocket();
974
+ closeSocket({ trackClose: true });
841
975
  }
842
976
  );
843
977
  const parseMessage = useEffectEvent((event) => {
@@ -891,10 +1025,17 @@ var useWebSocket = (options) => {
891
1025
  lastError: parseError,
892
1026
  status: "error"
893
1027
  }));
894
- closeSocket(1003, "parse-error");
1028
+ closeSocket({
1029
+ code: 1003,
1030
+ reason: "parse-error",
1031
+ trackClose: true
1032
+ });
895
1033
  }
896
1034
  });
897
- const handleError = useEffectEvent((event) => {
1035
+ const handleError = useEffectEvent((event, socketEpoch) => {
1036
+ if (!isActiveSocketEvent(socketEpoch)) {
1037
+ return;
1038
+ }
898
1039
  heartbeat.stop();
899
1040
  commitState((current) => ({
900
1041
  ...current,
@@ -904,9 +1045,18 @@ var useWebSocket = (options) => {
904
1045
  }));
905
1046
  options.onError?.(event);
906
1047
  });
907
- const handleClose = useEffectEvent((event) => {
908
- socketRef.current = null;
909
- socketKeyRef.current = null;
1048
+ const handleClose = useEffectEvent((event, socketEpoch) => {
1049
+ if (!shouldHandleSocketClose(socketEpoch)) {
1050
+ return;
1051
+ }
1052
+ if (activeSocketEpochRef.current === socketEpoch) {
1053
+ socketRef.current = null;
1054
+ socketKeyRef.current = null;
1055
+ activeSocketEpochRef.current = null;
1056
+ }
1057
+ if (closingSocketEpochRef.current === socketEpoch) {
1058
+ closingSocketEpochRef.current = null;
1059
+ }
910
1060
  heartbeat.stop();
911
1061
  updateBufferedAmount();
912
1062
  const pendingCloseAction = pendingCloseActionRef.current;
@@ -986,7 +1136,11 @@ var useWebSocket = (options) => {
986
1136
  lastChangedAt: Date.now(),
987
1137
  status: "closing"
988
1138
  }));
989
- closeSocket(code, reason);
1139
+ closeSocket({
1140
+ code,
1141
+ reason,
1142
+ trackClose: true
1143
+ });
990
1144
  };
991
1145
  const send = (message) => {
992
1146
  const socket2 = socketRef.current;
@@ -1038,8 +1192,12 @@ var useWebSocket = (options) => {
1038
1192
  return;
1039
1193
  }
1040
1194
  const socket2 = new WebSocket(resolvedUrl, options.protocols);
1195
+ const socketEpoch = nextSocketEpochRef.current + 1;
1041
1196
  socketRef.current = socket2;
1042
1197
  socketKeyRef.current = nextSocketKey;
1198
+ activeSocketEpochRef.current = socketEpoch;
1199
+ closingSocketEpochRef.current = null;
1200
+ nextSocketEpochRef.current = socketEpoch;
1043
1201
  socket2.binaryType = options.binaryType ?? "blob";
1044
1202
  commitState((current) => ({
1045
1203
  ...current,
@@ -1048,16 +1206,22 @@ var useWebSocket = (options) => {
1048
1206
  status: reconnect.status === "running" || reconnect.status === "scheduled" ? "reconnecting" : "connecting"
1049
1207
  }));
1050
1208
  const handleSocketOpen = (event) => {
1209
+ if (!isActiveSocketEvent(socketEpoch)) {
1210
+ return;
1211
+ }
1051
1212
  handleOpen(event, socket2);
1052
1213
  };
1053
1214
  const handleSocketMessage = (event) => {
1215
+ if (!isActiveSocketEvent(socketEpoch)) {
1216
+ return;
1217
+ }
1054
1218
  handleMessage(event);
1055
1219
  };
1056
1220
  const handleSocketError = (event) => {
1057
- handleError(event);
1221
+ handleError(event, socketEpoch);
1058
1222
  };
1059
1223
  const handleSocketClose = (event) => {
1060
- handleClose(event);
1224
+ handleClose(event, socketEpoch);
1061
1225
  };
1062
1226
  socket2.addEventListener("open", handleSocketOpen);
1063
1227
  socket2.addEventListener("message", handleSocketMessage);
@@ -1081,6 +1245,8 @@ var useWebSocket = (options) => {
1081
1245
  useEffect(() => () => {
1082
1246
  suppressReconnectRef.current = true;
1083
1247
  socketKeyRef.current = null;
1248
+ activeSocketEpochRef.current = null;
1249
+ closingSocketEpochRef.current = null;
1084
1250
  terminalErrorRef.current = null;
1085
1251
  const socket2 = socketRef.current;
1086
1252
  socketRef.current = null;
@@ -1458,6 +1624,6 @@ var useEventSource = (options) => {
1458
1624
  };
1459
1625
  };
1460
1626
 
1461
- export { useEventSource, useHeartbeat, useOnlineStatus, usePageVisibility, useReconnect, useWebSocket };
1627
+ export { useConnectionGate, useEventSource, useHeartbeat, useOnlineStatus, usePageVisibility, useReconnect, useWebSocket };
1462
1628
  //# sourceMappingURL=index.js.map
1463
1629
  //# sourceMappingURL=index.js.map