react-realtime-hooks 1.1.0 → 1.2.0

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
@@ -7,9 +7,9 @@
7
7
  [![TypeScript](https://img.shields.io/badge/TypeScript-typed-3178c6)](https://www.typescriptlang.org/)
8
8
  [![react](https://img.shields.io/badge/react-19.x-149eca)](https://www.npmjs.com/package/react)
9
9
 
10
- Production-ready React hooks for WebSocket and SSE with auto-reconnect, heartbeat, typed connection state, and browser network awareness including page visibility.
10
+ Production-ready React hooks for WebSocket and SSE with auto-reconnect, heartbeat, typed connection state, and browser network awareness including page visibility and connection gating.
11
11
 
12
- `react-realtime-hooks` is for apps that need more than "open a socket and hope for the best". It gives you composable hooks for transport lifecycle, retry strategy, heartbeat, online status, and page visibility, so your UI can react to realtime state without rebuilding the same connection logic in every screen.
12
+ `react-realtime-hooks` is for apps that need more than "open a socket and hope for the best". It gives you composable hooks for transport lifecycle, retry strategy, heartbeat, online status, page visibility, and connection gating, so your UI can react to realtime state without rebuilding the same connection logic in every screen.
13
13
 
14
14
  Live demo: https://volkov85.github.io/react-realtime-hooks/
15
15
 
@@ -24,6 +24,7 @@ Real apps need:
24
24
  - heartbeat and timeout tracking
25
25
  - clean SSR behavior
26
26
  - browser network and page visibility awareness
27
+ - environment-aware connection gating for offline state and background tabs
27
28
  - typed message parsing and sending
28
29
 
29
30
  `react-realtime-hooks` packages those concerns into small hooks that compose cleanly in React.
@@ -33,6 +34,7 @@ Real apps need:
33
34
  - `useWebSocket` and `useEventSource` return state you can render, not just transport instances.
34
35
  - Built-in reconnect flow with exponential backoff, jitter, attempt limits, and manual restart.
35
36
  - Heartbeat support with ack matching, timeout detection, and latency measurement.
37
+ - `useConnectionGate` turns online and visibility signals into a single `connect` flag for transport hooks.
36
38
  - Discriminated connection snapshots: `idle`, `connecting`, `open`, `reconnecting`, `closing`, `closed`, `error`.
37
39
  - First-class TypeScript support with generic message types and custom parsers/serializers.
38
40
  - SSR-safe by default. No browser-only globals are touched during server render.
@@ -46,7 +48,7 @@ Real apps need:
46
48
  | Connection state | You model it yourself | Built-in status model you can render directly |
47
49
  | Reconnect flow | Manual timers and teardown | `useReconnect` with backoff, jitter, and limits |
48
50
  | Heartbeat | Custom ping/pong loop | `heartbeat` support with timeout and latency |
49
- | Browser awareness | Separate browser event wiring | `useOnlineStatus` and `usePageVisibility` for browser state |
51
+ | Browser awareness | Separate browser event wiring | `useOnlineStatus`, `usePageVisibility`, and `useConnectionGate` for browser-aware state |
50
52
  | SSR safety | Easy to break during render | Browser-only behavior stays out of server render |
51
53
  | UI ergonomics | Event handlers and refs everywhere | Hook result already shaped for product UI |
52
54
 
@@ -151,7 +153,7 @@ Browser APIs
151
153
  WebSocket / EventSource / navigator.onLine
152
154
 
153
155
  Core hooks
154
- useReconnect / useHeartbeat / useOnlineStatus / usePageVisibility
156
+ useReconnect / useHeartbeat / useOnlineStatus / usePageVisibility / useConnectionGate
155
157
 
156
158
  Transport hooks
157
159
  useWebSocket / useEventSource
@@ -209,6 +211,7 @@ This library already models those edges in a reusable way.
209
211
  | `useEventSource` | Server-Sent Events streams | `status`, `eventSource`, `lastMessage`, `lastEventName`, `reconnect()` |
210
212
  | `useReconnect` | Reusable retry and backoff logic | `schedule()`, `cancel()`, `reset()`, `attempt`, `status` |
211
213
  | `useHeartbeat` | Liveness checks and timeout tracking | `start()`, `stop()`, `beat()`, `notifyAck()`, `latencyMs` |
214
+ | `useConnectionGate` | Browser-aware transport gating | `connect`, `reason`, `isBlocked`, gate transition timestamps |
212
215
  | `useOnlineStatus` | Browser online/offline state | `isOnline`, `isSupported`, transition timestamps |
213
216
  | `usePageVisibility` | Browser tab/page visibility state | `isVisible`, `visibilityState`, `isSupported`, transition timestamps |
214
217
 
@@ -357,6 +360,33 @@ export function AttentionAwareBadge() {
357
360
  );
358
361
  }
359
362
  ```
363
+ ### `useConnectionGate`
364
+
365
+ ```tsx
366
+ import { useConnectionGate, useWebSocket } from "react-realtime-hooks";
367
+
368
+ export function GatedNotifications() {
369
+ const gate = useConnectionGate({
370
+ requireOnline: true,
371
+ requireVisible: true,
372
+ hiddenGraceMs: 30_000,
373
+ });
374
+ const socket = useWebSocket({
375
+ connect: gate.connect,
376
+ reconnect: {
377
+ initialDelayMs: 1_000,
378
+ maxAttempts: null,
379
+ },
380
+ url: "ws://localhost:8080/notifications",
381
+ });
382
+
383
+ return (
384
+ <div>
385
+ Gate: {gate.reason} | Transport: {socket.status}
386
+ </div>
387
+ );
388
+ }
389
+ ```
360
390
  ## API Reference
361
391
 
362
392
  <details>
@@ -509,6 +539,42 @@ When you configure `useWebSocket` heartbeat, you can also set `timeoutAction` an
509
539
 
510
540
  </details>
511
541
 
542
+ <details>
543
+ <summary><strong>useConnectionGate</strong></summary>
544
+
545
+ ### Options
546
+
547
+ | Option | Type | Default | Description |
548
+ | ------------------ | --------- | ------- | ------------------------------------------------------------------ |
549
+ | `enabled` | `boolean` | `true` | Master on/off switch for the gate |
550
+ | `requireOnline` | `boolean` | `true` | Blocks `connect` when the browser reports offline |
551
+ | `requireVisible` | `boolean` | `false` | Blocks `connect` when the page is hidden |
552
+ | `hiddenGraceMs` | `number` | `0` | Delay before hidden pages are blocked |
553
+ | `initialOnline` | `boolean` | `true` | Fallback value when `navigator.onLine` is unavailable |
554
+ | `initialVisible` | `boolean` | `true` | Fallback value when the Visibility API is unavailable |
555
+ | `trackTransitions` | `boolean` | `true` | Tracks `lastChangedAt`, `becameReadyAt`, and `becameBlockedAt` |
556
+
557
+ ### Result
558
+
559
+ | Field | Type | Description |
560
+ | -------------------------- | ------------------------------------------ | ---------------------------------------------------------- |
561
+ | `connect` | `boolean` | Flag to pass into `useWebSocket` or `useEventSource` |
562
+ | `isBlocked` | `boolean` | Whether the gate is currently blocking connection |
563
+ | `reason` | `"ready" \| "manual" \| "offline" \| "hidden"` | Current gate reason |
564
+ | `isWaitingForVisibleGrace` | `boolean` | `true` while a hidden-tab grace window is still active |
565
+ | `isOnline` | `boolean` | Current browser online state |
566
+ | `isOnlineSupported` | `boolean` | Whether `navigator.onLine` is available |
567
+ | `isVisible` | `boolean` | Whether the current page is visible |
568
+ | `isVisibilitySupported` | `boolean` | Whether `document.visibilityState` is available |
569
+ | `visibilityState` | `DocumentVisibilityState \| "visible"` | Current browser visibility state |
570
+ | `lastChangedAt` | `number \| null` | Timestamp of the last gate state change |
571
+ | `becameReadyAt` | `number \| null` | Timestamp of the last transition into `reason === "ready"` |
572
+ | `becameBlockedAt` | `number \| null` | Timestamp of the last transition into a blocked state |
573
+
574
+ `reason` priority is deterministic: `manual` overrides `offline`, and `offline` overrides `hidden`.
575
+ That keeps the gate predictable when multiple blockers apply at once.
576
+
577
+ </details>
512
578
  <details>
513
579
  <summary><strong>useOnlineStatus</strong></summary>
514
580
 
@@ -600,3 +666,4 @@ MIT
600
666
 
601
667
 
602
668
 
669
+
package/dist/index.cjs CHANGED
@@ -138,6 +138,177 @@ var usePageVisibility = (options = {}) => {
138
138
  };
139
139
  };
140
140
 
141
+ // src/core/timers.ts
142
+ var sanitizeTimerDelay = (delayMs) => {
143
+ if (!Number.isFinite(delayMs)) {
144
+ return 0;
145
+ }
146
+ return Math.max(0, Math.round(delayMs));
147
+ };
148
+ var createManagedTimeout = () => {
149
+ let timeoutId = null;
150
+ return {
151
+ cancel() {
152
+ if (timeoutId !== null) {
153
+ clearTimeout(timeoutId);
154
+ timeoutId = null;
155
+ }
156
+ },
157
+ isActive() {
158
+ return timeoutId !== null;
159
+ },
160
+ schedule(callback, delayMs) {
161
+ if (timeoutId !== null) {
162
+ clearTimeout(timeoutId);
163
+ }
164
+ timeoutId = setTimeout(() => {
165
+ timeoutId = null;
166
+ callback();
167
+ }, sanitizeTimerDelay(delayMs));
168
+ }
169
+ };
170
+ };
171
+ var createManagedInterval = () => {
172
+ let intervalId = null;
173
+ return {
174
+ cancel() {
175
+ if (intervalId !== null) {
176
+ clearInterval(intervalId);
177
+ intervalId = null;
178
+ }
179
+ },
180
+ isActive() {
181
+ return intervalId !== null;
182
+ },
183
+ start(callback, intervalMs) {
184
+ if (intervalId !== null) {
185
+ clearInterval(intervalId);
186
+ }
187
+ intervalId = setInterval(callback, sanitizeTimerDelay(intervalMs));
188
+ }
189
+ };
190
+ };
191
+
192
+ // src/hooks/useConnectionGate.ts
193
+ var createEmptyTransitionState3 = () => ({
194
+ becameBlockedAt: null,
195
+ becameReadyAt: null,
196
+ lastChangedAt: null
197
+ });
198
+ var normalizeHiddenGraceMs = (value) => {
199
+ if (value === void 0 || !Number.isFinite(value)) {
200
+ return 0;
201
+ }
202
+ return Math.max(0, value);
203
+ };
204
+ var useConnectionGate = (options = {}) => {
205
+ const enabled = options.enabled ?? true;
206
+ const requireOnline = options.requireOnline ?? true;
207
+ const requireVisible = options.requireVisible ?? false;
208
+ const hiddenGraceMs = normalizeHiddenGraceMs(options.hiddenGraceMs);
209
+ const trackTransitions = options.trackTransitions ?? true;
210
+ const onlineStatus = useOnlineStatus({
211
+ ...options.initialOnline === void 0 ? {} : { initialOnline: options.initialOnline },
212
+ trackTransitions: false
213
+ });
214
+ const pageVisibility = usePageVisibility({
215
+ ...options.initialVisible === void 0 ? {} : { initialVisible: options.initialVisible },
216
+ trackTransitions: false
217
+ });
218
+ const hiddenGraceTimeoutRef = react.useRef(createManagedTimeout());
219
+ const hiddenSinceRef = react.useRef(null);
220
+ const previousStateRef = react.useRef(null);
221
+ const [hasExceededHiddenGrace, setHasExceededHiddenGrace] = react.useState(false);
222
+ const [isWaitingForVisibleGrace, setIsWaitingForVisibleGrace] = react.useState(false);
223
+ const [transitions, setTransitions] = react.useState(createEmptyTransitionState3);
224
+ react.useEffect(() => () => {
225
+ hiddenGraceTimeoutRef.current.cancel();
226
+ }, []);
227
+ react.useEffect(() => {
228
+ hiddenGraceTimeoutRef.current.cancel();
229
+ if (!requireVisible || pageVisibility.isVisible) {
230
+ hiddenSinceRef.current = null;
231
+ setHasExceededHiddenGrace(false);
232
+ setIsWaitingForVisibleGrace(false);
233
+ return;
234
+ }
235
+ const hiddenSince = hiddenSinceRef.current ?? Date.now();
236
+ hiddenSinceRef.current = hiddenSince;
237
+ if (hiddenGraceMs <= 0) {
238
+ setHasExceededHiddenGrace(true);
239
+ setIsWaitingForVisibleGrace(false);
240
+ return;
241
+ }
242
+ const elapsedMs = Date.now() - hiddenSince;
243
+ if (elapsedMs >= hiddenGraceMs) {
244
+ setHasExceededHiddenGrace(true);
245
+ setIsWaitingForVisibleGrace(false);
246
+ return;
247
+ }
248
+ setHasExceededHiddenGrace(false);
249
+ setIsWaitingForVisibleGrace(true);
250
+ hiddenGraceTimeoutRef.current.schedule(() => {
251
+ setHasExceededHiddenGrace(true);
252
+ setIsWaitingForVisibleGrace(false);
253
+ }, hiddenGraceMs - elapsedMs);
254
+ }, [hiddenGraceMs, pageVisibility.isVisible, requireVisible]);
255
+ let reason = "ready";
256
+ if (!enabled) {
257
+ reason = "manual";
258
+ } else if (requireOnline && !onlineStatus.isOnline) {
259
+ reason = "offline";
260
+ } else if (requireVisible && !pageVisibility.isVisible && hasExceededHiddenGrace) {
261
+ reason = "hidden";
262
+ }
263
+ const connect = reason === "ready";
264
+ const isBlocked = !connect;
265
+ react.useEffect(() => {
266
+ if (!trackTransitions) {
267
+ previousStateRef.current = {
268
+ connect,
269
+ reason
270
+ };
271
+ setTransitions(createEmptyTransitionState3);
272
+ return;
273
+ }
274
+ const previousState = previousStateRef.current;
275
+ if (previousState === null) {
276
+ previousStateRef.current = {
277
+ connect,
278
+ reason
279
+ };
280
+ return;
281
+ }
282
+ if (previousState.connect === connect && previousState.reason === reason) {
283
+ return;
284
+ }
285
+ const changedAt = Date.now();
286
+ previousStateRef.current = {
287
+ connect,
288
+ reason
289
+ };
290
+ setTransitions((current) => ({
291
+ becameBlockedAt: connect ? current.becameBlockedAt : changedAt,
292
+ becameReadyAt: connect ? changedAt : current.becameReadyAt,
293
+ lastChangedAt: changedAt
294
+ }));
295
+ }, [connect, reason, trackTransitions]);
296
+ return {
297
+ becameBlockedAt: transitions.becameBlockedAt,
298
+ becameReadyAt: transitions.becameReadyAt,
299
+ connect,
300
+ isBlocked,
301
+ isOnline: onlineStatus.isOnline,
302
+ isOnlineSupported: onlineStatus.isSupported,
303
+ isVisibilitySupported: pageVisibility.isSupported,
304
+ isVisible: pageVisibility.isVisible,
305
+ isWaitingForVisibleGrace,
306
+ lastChangedAt: transitions.lastChangedAt,
307
+ reason,
308
+ visibilityState: pageVisibility.visibilityState
309
+ };
310
+ };
311
+
141
312
  // src/core/reconnect.ts
142
313
  var DEFAULT_RECONNECT_OPTIONS = {
143
314
  backoffFactor: 2,
@@ -274,57 +445,6 @@ var createReconnectAttempt = (attempt, trigger, options, lastDelayMs, config = {
274
445
  };
275
446
  };
276
447
 
277
- // src/core/timers.ts
278
- var sanitizeTimerDelay = (delayMs) => {
279
- if (!Number.isFinite(delayMs)) {
280
- return 0;
281
- }
282
- return Math.max(0, Math.round(delayMs));
283
- };
284
- var createManagedTimeout = () => {
285
- let timeoutId = null;
286
- return {
287
- cancel() {
288
- if (timeoutId !== null) {
289
- clearTimeout(timeoutId);
290
- timeoutId = null;
291
- }
292
- },
293
- isActive() {
294
- return timeoutId !== null;
295
- },
296
- schedule(callback, delayMs) {
297
- if (timeoutId !== null) {
298
- clearTimeout(timeoutId);
299
- }
300
- timeoutId = setTimeout(() => {
301
- timeoutId = null;
302
- callback();
303
- }, sanitizeTimerDelay(delayMs));
304
- }
305
- };
306
- };
307
- var createManagedInterval = () => {
308
- let intervalId = null;
309
- return {
310
- cancel() {
311
- if (intervalId !== null) {
312
- clearInterval(intervalId);
313
- intervalId = null;
314
- }
315
- },
316
- isActive() {
317
- return intervalId !== null;
318
- },
319
- start(callback, intervalMs) {
320
- if (intervalId !== null) {
321
- clearInterval(intervalId);
322
- }
323
- intervalId = setInterval(callback, sanitizeTimerDelay(intervalMs));
324
- }
325
- };
326
- };
327
-
328
448
  // src/hooks/useReconnect.ts
329
449
  var createInitialState = (enabled) => ({
330
450
  attempt: 0,
@@ -1460,6 +1580,7 @@ var useEventSource = (options) => {
1460
1580
  };
1461
1581
  };
1462
1582
 
1583
+ exports.useConnectionGate = useConnectionGate;
1463
1584
  exports.useEventSource = useEventSource;
1464
1585
  exports.useHeartbeat = useHeartbeat;
1465
1586
  exports.useOnlineStatus = useOnlineStatus;