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 +71 -4
- package/dist/index.cjs +172 -51
- package/dist/index.cjs.map +1 -1
- package/dist/index.d.cts +29 -1
- package/dist/index.d.ts +29 -1
- package/dist/index.js +172 -52
- package/dist/index.js.map +1 -1
- package/package.json +1 -1
package/README.md
CHANGED
|
@@ -7,9 +7,9 @@
|
|
|
7
7
|
[](https://www.typescriptlang.org/)
|
|
8
8
|
[](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,
|
|
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 `
|
|
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;
|