react-realtime-hooks 1.0.3 → 1.1.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 +64 -14
- package/dist/index.cjs +71 -5
- package/dist/index.cjs.map +1 -1
- package/dist/index.d.cts +17 -1
- package/dist/index.d.ts +17 -1
- package/dist/index.js +71 -6
- package/dist/index.js.map +1 -1
- package/package.json +2 -2
package/README.md
CHANGED
|
@@ -5,11 +5,11 @@
|
|
|
5
5
|
[](https://github.com/volkov85/react-realtime-hooks/actions/workflows/pages.yml)
|
|
6
6
|
[](https://github.com/volkov85/react-realtime-hooks/blob/main/LICENSE)
|
|
7
7
|
[](https://www.typescriptlang.org/)
|
|
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.
|
|
10
|
+
Production-ready React hooks for WebSocket and SSE with auto-reconnect, heartbeat, typed connection state, and browser network awareness including page visibility.
|
|
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,
|
|
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.
|
|
13
13
|
|
|
14
14
|
Live demo: https://volkov85.github.io/react-realtime-hooks/
|
|
15
15
|
|
|
@@ -23,7 +23,7 @@ Real apps need:
|
|
|
23
23
|
- reconnect strategy with caps, jitter, and manual control
|
|
24
24
|
- heartbeat and timeout tracking
|
|
25
25
|
- clean SSR behavior
|
|
26
|
-
- browser network awareness
|
|
26
|
+
- browser network and page visibility awareness
|
|
27
27
|
- typed message parsing and sending
|
|
28
28
|
|
|
29
29
|
`react-realtime-hooks` packages those concerns into small hooks that compose cleanly in React.
|
|
@@ -46,7 +46,7 @@ Real apps need:
|
|
|
46
46
|
| Connection state | You model it yourself | Built-in status model you can render directly |
|
|
47
47
|
| Reconnect flow | Manual timers and teardown | `useReconnect` with backoff, jitter, and limits |
|
|
48
48
|
| Heartbeat | Custom ping/pong loop | `heartbeat` support with timeout and latency |
|
|
49
|
-
|
|
|
49
|
+
| Browser awareness | Separate browser event wiring | `useOnlineStatus` and `usePageVisibility` for browser state |
|
|
50
50
|
| SSR safety | Easy to break during render | Browser-only behavior stays out of server render |
|
|
51
51
|
| UI ergonomics | Event handlers and refs everywhere | Hook result already shaped for product UI |
|
|
52
52
|
|
|
@@ -60,12 +60,16 @@ npm install react-realtime-hooks
|
|
|
60
60
|
|
|
61
61
|
Peer dependency:
|
|
62
62
|
|
|
63
|
-
- `react@^
|
|
63
|
+
- `react@^19.0.0`
|
|
64
64
|
|
|
65
65
|
## How It Feels
|
|
66
66
|
|
|
67
67
|
```tsx
|
|
68
|
-
import {
|
|
68
|
+
import {
|
|
69
|
+
useOnlineStatus,
|
|
70
|
+
usePageVisibility,
|
|
71
|
+
useWebSocket
|
|
72
|
+
} from "react-realtime-hooks";
|
|
69
73
|
|
|
70
74
|
type IncomingMessage =
|
|
71
75
|
| { type: "notification"; text: string }
|
|
@@ -75,6 +79,7 @@ type OutgoingMessage = { type: "ack"; id: string } | { type: "ping" };
|
|
|
75
79
|
|
|
76
80
|
export function NotificationsPanel() {
|
|
77
81
|
const network = useOnlineStatus();
|
|
82
|
+
const page = usePageVisibility();
|
|
78
83
|
const socket = useWebSocket<IncomingMessage, OutgoingMessage>({
|
|
79
84
|
url: "ws://localhost:8080/notifications",
|
|
80
85
|
parseMessage: (event) => JSON.parse(String(event.data)) as IncomingMessage,
|
|
@@ -93,8 +98,8 @@ export function NotificationsPanel() {
|
|
|
93
98
|
return (
|
|
94
99
|
<section>
|
|
95
100
|
<p>
|
|
96
|
-
|
|
97
|
-
{socket.status}
|
|
101
|
+
Page: {page.isVisible ? "visible" : "hidden"} | Network:{" "}
|
|
102
|
+
{network.isOnline ? "online" : "offline"} | Transport: {socket.status}
|
|
98
103
|
</p>
|
|
99
104
|
|
|
100
105
|
{socket.status === "reconnecting" && (
|
|
@@ -146,7 +151,7 @@ Browser APIs
|
|
|
146
151
|
WebSocket / EventSource / navigator.onLine
|
|
147
152
|
|
|
148
153
|
Core hooks
|
|
149
|
-
useReconnect / useHeartbeat / useOnlineStatus
|
|
154
|
+
useReconnect / useHeartbeat / useOnlineStatus / usePageVisibility
|
|
150
155
|
|
|
151
156
|
Transport hooks
|
|
152
157
|
useWebSocket / useEventSource
|
|
@@ -205,6 +210,7 @@ This library already models those edges in a reusable way.
|
|
|
205
210
|
| `useReconnect` | Reusable retry and backoff logic | `schedule()`, `cancel()`, `reset()`, `attempt`, `status` |
|
|
206
211
|
| `useHeartbeat` | Liveness checks and timeout tracking | `start()`, `stop()`, `beat()`, `notifyAck()`, `latencyMs` |
|
|
207
212
|
| `useOnlineStatus` | Browser online/offline state | `isOnline`, `isSupported`, transition timestamps |
|
|
213
|
+
| `usePageVisibility` | Browser tab/page visibility state | `isVisible`, `visibilityState`, `isSupported`, transition timestamps |
|
|
208
214
|
|
|
209
215
|
## Transport Examples
|
|
210
216
|
|
|
@@ -334,6 +340,23 @@ export function NetworkIndicator() {
|
|
|
334
340
|
}
|
|
335
341
|
```
|
|
336
342
|
|
|
343
|
+
### `usePageVisibility`
|
|
344
|
+
|
|
345
|
+
```tsx
|
|
346
|
+
import { usePageVisibility } from "react-realtime-hooks";
|
|
347
|
+
|
|
348
|
+
export function AttentionAwareBadge() {
|
|
349
|
+
const page = usePageVisibility({
|
|
350
|
+
trackTransitions: true,
|
|
351
|
+
});
|
|
352
|
+
|
|
353
|
+
return (
|
|
354
|
+
<span>
|
|
355
|
+
{page.isVisible ? "Active tab" : "Background tab"} ({page.visibilityState})
|
|
356
|
+
</span>
|
|
357
|
+
);
|
|
358
|
+
}
|
|
359
|
+
```
|
|
337
360
|
## API Reference
|
|
338
361
|
|
|
339
362
|
<details>
|
|
@@ -354,7 +377,7 @@ export function NetworkIndicator() {
|
|
|
354
377
|
| `shouldReconnect` | `(event) => boolean` | `true` | Reconnect gate on close |
|
|
355
378
|
| `onOpen` | `(event, socket) => void` | `undefined` | Open callback |
|
|
356
379
|
| `onMessage` | `(message, event) => void` | `undefined` | Message callback |
|
|
357
|
-
| `onError` | `(event) => void` | `undefined` |
|
|
380
|
+
| `onError` | `(event) => void` | `undefined` | Called for transport, heartbeat, and parse errors |
|
|
358
381
|
| `onClose` | `(event) => void` | `undefined` | Close callback |
|
|
359
382
|
|
|
360
383
|
### Result
|
|
@@ -397,7 +420,7 @@ When you configure `useWebSocket` heartbeat, you can also set `timeoutAction` an
|
|
|
397
420
|
| `shouldReconnect` | `(event) => boolean` | `true` | Reconnect gate on error |
|
|
398
421
|
| `onOpen` | `(event, source) => void` | `undefined` | Open callback |
|
|
399
422
|
| `onMessage` | `(message, event) => void` | `undefined` | Default `message` callback |
|
|
400
|
-
| `onError` | `(event) => void` | `undefined` |
|
|
423
|
+
| `onError` | `(event) => void` | `undefined` | Called for transport and parse errors |
|
|
401
424
|
| `onEvent` | `(eventName, message, event) => void` | `undefined` | Named event callback |
|
|
402
425
|
|
|
403
426
|
### Result
|
|
@@ -508,11 +531,34 @@ When you configure `useWebSocket` heartbeat, you can also set `timeoutAction` an
|
|
|
508
531
|
|
|
509
532
|
</details>
|
|
510
533
|
|
|
534
|
+
<details>
|
|
535
|
+
<summary><strong>usePageVisibility</strong></summary>
|
|
536
|
+
|
|
537
|
+
### Options
|
|
538
|
+
|
|
539
|
+
| Option | Type | Default | Description |
|
|
540
|
+
| ------------------ | --------- | ------- | ----------------------------------------------------------- |
|
|
541
|
+
| `initialVisible` | `boolean` | `true` | Fallback value when the Visibility API is unavailable |
|
|
542
|
+
| `trackTransitions` | `boolean` | `true` | Tracks `lastChangedAt`, `becameVisibleAt`, `becameHiddenAt` |
|
|
543
|
+
|
|
544
|
+
### Result
|
|
545
|
+
|
|
546
|
+
| Field | Type | Description |
|
|
547
|
+
| ----------------- | ------------------------------------ | ------------------------------------------------ |
|
|
548
|
+
| `isVisible` | `boolean` | Whether the current page is visible |
|
|
549
|
+
| `visibilityState` | `DocumentVisibilityState \| "visible"` | Current browser visibility state |
|
|
550
|
+
| `isSupported` | `boolean` | Whether `document.visibilityState` is available |
|
|
551
|
+
| `lastChangedAt` | `number \| null` | Timestamp of the last visibility transition |
|
|
552
|
+
| `becameVisibleAt` | `number \| null` | Timestamp of the last visible transition |
|
|
553
|
+
| `becameHiddenAt` | `number \| null` | Timestamp of the last hidden transition |
|
|
554
|
+
|
|
555
|
+
</details>
|
|
511
556
|
## Limitations And Edge Cases
|
|
512
557
|
|
|
513
558
|
- `useEventSource` is receive-only by design. SSE is not a bidirectional transport.
|
|
514
559
|
- `useWebSocket` heartbeat support is client-side. You still define your own server ping/pong protocol.
|
|
515
|
-
- If `parseMessage` throws, the hook closes the current transport, moves into `error`, stores `lastError`, and stops auto-reconnect until manual `open()` or `reconnect()`.
|
|
560
|
+
- If `parseMessage` throws, the hook calls `onError`, closes the current transport, moves into `error`, stores `lastError`, and stops auto-reconnect until manual `open()` or `reconnect()`.
|
|
561
|
+
- Stopping heartbeat clears timeout state and the previous beat/ack timestamps so a new session starts with fresh metrics.
|
|
516
562
|
- `connect: false` keeps the hook in `idle` until `open()` is called.
|
|
517
563
|
- Manual `close()` is sticky. The hook stays closed until `open()` or `reconnect()` is called.
|
|
518
564
|
- No transport polyfills are bundled. Provide your own runtime support where needed.
|
|
@@ -526,7 +572,7 @@ The package includes behavior tests for:
|
|
|
526
572
|
- exponential backoff
|
|
527
573
|
- timer and listener cleanup
|
|
528
574
|
- heartbeat start / stop / timeout
|
|
529
|
-
- browser offline / online transitions
|
|
575
|
+
- browser offline / online and page visibility transitions
|
|
530
576
|
- invalid payload and parse errors
|
|
531
577
|
- manual reconnect and manual close
|
|
532
578
|
|
|
@@ -550,3 +596,7 @@ Development and release workflow live in [CONTRIBUTING.md](./CONTRIBUTING.md).
|
|
|
550
596
|
## License
|
|
551
597
|
|
|
552
598
|
MIT
|
|
599
|
+
|
|
600
|
+
|
|
601
|
+
|
|
602
|
+
|
package/dist/index.cjs
CHANGED
|
@@ -8,6 +8,7 @@ var react = require('react');
|
|
|
8
8
|
var isWebSocketSupported = () => typeof WebSocket !== "undefined";
|
|
9
9
|
var isEventSourceSupported = () => typeof EventSource !== "undefined";
|
|
10
10
|
var hasNavigatorOnLineSupport = () => typeof navigator !== "undefined" && typeof navigator.onLine === "boolean";
|
|
11
|
+
var hasDocumentVisibilitySupport = () => typeof document !== "undefined" && typeof document.visibilityState === "string";
|
|
11
12
|
var readOnlineStatus = (initialOnline = true) => {
|
|
12
13
|
if (!hasNavigatorOnLineSupport()) {
|
|
13
14
|
return {
|
|
@@ -20,6 +21,20 @@ var readOnlineStatus = (initialOnline = true) => {
|
|
|
20
21
|
isSupported: true
|
|
21
22
|
};
|
|
22
23
|
};
|
|
24
|
+
var readPageVisibility = (initialVisible = true) => {
|
|
25
|
+
if (!hasDocumentVisibilitySupport()) {
|
|
26
|
+
return {
|
|
27
|
+
isVisible: initialVisible,
|
|
28
|
+
isSupported: false,
|
|
29
|
+
visibilityState: initialVisible ? "visible" : "hidden"
|
|
30
|
+
};
|
|
31
|
+
}
|
|
32
|
+
return {
|
|
33
|
+
isVisible: document.visibilityState === "visible",
|
|
34
|
+
isSupported: true,
|
|
35
|
+
visibilityState: document.visibilityState
|
|
36
|
+
};
|
|
37
|
+
};
|
|
23
38
|
|
|
24
39
|
// src/hooks/useOnlineStatus.ts
|
|
25
40
|
var subscribeToOnlineStatus = (onStoreChange) => {
|
|
@@ -72,6 +87,56 @@ var useOnlineStatus = (options = {}) => {
|
|
|
72
87
|
...transitions
|
|
73
88
|
};
|
|
74
89
|
};
|
|
90
|
+
var subscribeToPageVisibility = (onStoreChange) => {
|
|
91
|
+
if (typeof document === "undefined" || !hasDocumentVisibilitySupport()) {
|
|
92
|
+
return () => {
|
|
93
|
+
};
|
|
94
|
+
}
|
|
95
|
+
document.addEventListener("visibilitychange", onStoreChange);
|
|
96
|
+
return () => {
|
|
97
|
+
document.removeEventListener("visibilitychange", onStoreChange);
|
|
98
|
+
};
|
|
99
|
+
};
|
|
100
|
+
var createEmptyTransitionState2 = () => ({
|
|
101
|
+
lastChangedAt: null,
|
|
102
|
+
becameHiddenAt: null,
|
|
103
|
+
becameVisibleAt: null
|
|
104
|
+
});
|
|
105
|
+
var usePageVisibility = (options = {}) => {
|
|
106
|
+
const initialVisible = options.initialVisible ?? true;
|
|
107
|
+
const trackTransitions = options.trackTransitions ?? true;
|
|
108
|
+
const visibilityState = react.useSyncExternalStore(
|
|
109
|
+
subscribeToPageVisibility,
|
|
110
|
+
() => readPageVisibility(initialVisible).visibilityState,
|
|
111
|
+
() => initialVisible ? "visible" : "hidden"
|
|
112
|
+
);
|
|
113
|
+
const isVisible = visibilityState === "visible";
|
|
114
|
+
const previousVisibleRef = react.useRef(isVisible);
|
|
115
|
+
const [transitions, setTransitions] = react.useState(createEmptyTransitionState2);
|
|
116
|
+
react.useEffect(() => {
|
|
117
|
+
if (!trackTransitions) {
|
|
118
|
+
previousVisibleRef.current = isVisible;
|
|
119
|
+
setTransitions(createEmptyTransitionState2);
|
|
120
|
+
return;
|
|
121
|
+
}
|
|
122
|
+
if (previousVisibleRef.current === isVisible) {
|
|
123
|
+
return;
|
|
124
|
+
}
|
|
125
|
+
const changedAt = Date.now();
|
|
126
|
+
previousVisibleRef.current = isVisible;
|
|
127
|
+
setTransitions((current) => ({
|
|
128
|
+
lastChangedAt: changedAt,
|
|
129
|
+
becameHiddenAt: isVisible ? current.becameHiddenAt : changedAt,
|
|
130
|
+
becameVisibleAt: isVisible ? changedAt : current.becameVisibleAt
|
|
131
|
+
}));
|
|
132
|
+
}, [isVisible, trackTransitions]);
|
|
133
|
+
return {
|
|
134
|
+
isSupported: hasDocumentVisibilitySupport(),
|
|
135
|
+
isVisible,
|
|
136
|
+
visibilityState,
|
|
137
|
+
...transitions
|
|
138
|
+
};
|
|
139
|
+
};
|
|
75
140
|
|
|
76
141
|
// src/core/reconnect.ts
|
|
77
142
|
var DEFAULT_RECONNECT_OPTIONS = {
|
|
@@ -488,11 +553,7 @@ var useHeartbeat = (options) => {
|
|
|
488
553
|
generationRef.current += 1;
|
|
489
554
|
intervalRef.current.cancel();
|
|
490
555
|
timeoutRef.current.cancel();
|
|
491
|
-
commitState((
|
|
492
|
-
...current,
|
|
493
|
-
hasTimedOut: false,
|
|
494
|
-
isRunning: false
|
|
495
|
-
}));
|
|
556
|
+
commitState(createInitialState2(false));
|
|
496
557
|
};
|
|
497
558
|
const beat = () => {
|
|
498
559
|
if (!enabled) {
|
|
@@ -747,6 +808,7 @@ var useWebSocket = (options) => {
|
|
|
747
808
|
const applyHeartbeatAction = react.useEffectEvent(
|
|
748
809
|
(action, error, reconnectTrigger) => {
|
|
749
810
|
heartbeat.stop();
|
|
811
|
+
options.onError?.(error);
|
|
750
812
|
if (action === "none") {
|
|
751
813
|
commitState((current) => ({
|
|
752
814
|
...current,
|
|
@@ -824,6 +886,7 @@ var useWebSocket = (options) => {
|
|
|
824
886
|
suppressReconnectRef.current = true;
|
|
825
887
|
reconnect.cancel();
|
|
826
888
|
heartbeat.stop();
|
|
889
|
+
options.onError?.(parseError);
|
|
827
890
|
commitState((current) => ({
|
|
828
891
|
...current,
|
|
829
892
|
lastChangedAt: Date.now(),
|
|
@@ -837,6 +900,7 @@ var useWebSocket = (options) => {
|
|
|
837
900
|
heartbeat.stop();
|
|
838
901
|
commitState((current) => ({
|
|
839
902
|
...current,
|
|
903
|
+
lastChangedAt: Date.now(),
|
|
840
904
|
lastError: event,
|
|
841
905
|
status: "error"
|
|
842
906
|
}));
|
|
@@ -1183,6 +1247,7 @@ var useEventSource = (options) => {
|
|
|
1183
1247
|
suppressReconnectRef.current = true;
|
|
1184
1248
|
reconnect.cancel();
|
|
1185
1249
|
closeEventSource();
|
|
1250
|
+
options.onError?.(parseError);
|
|
1186
1251
|
commitState((current) => ({
|
|
1187
1252
|
...current,
|
|
1188
1253
|
lastChangedAt: Date.now(),
|
|
@@ -1398,6 +1463,7 @@ var useEventSource = (options) => {
|
|
|
1398
1463
|
exports.useEventSource = useEventSource;
|
|
1399
1464
|
exports.useHeartbeat = useHeartbeat;
|
|
1400
1465
|
exports.useOnlineStatus = useOnlineStatus;
|
|
1466
|
+
exports.usePageVisibility = usePageVisibility;
|
|
1401
1467
|
exports.useReconnect = useReconnect;
|
|
1402
1468
|
exports.useWebSocket = useWebSocket;
|
|
1403
1469
|
//# sourceMappingURL=index.cjs.map
|