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 CHANGED
@@ -5,11 +5,11 @@
5
5
  [![Demo](https://img.shields.io/github/actions/workflow/status/volkov85/react-realtime-hooks/pages.yml?branch=main&label=demo)](https://github.com/volkov85/react-realtime-hooks/actions/workflows/pages.yml)
6
6
  [![license](https://img.shields.io/npm/l/react-realtime-hooks)](https://github.com/volkov85/react-realtime-hooks/blob/main/LICENSE)
7
7
  [![TypeScript](https://img.shields.io/badge/TypeScript-typed-3178c6)](https://www.typescriptlang.org/)
8
- [![react](https://img.shields.io/badge/react-18.3%2B%20%7C%2019-149eca)](https://www.npmjs.com/package/react)
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.
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, and online status, 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, 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
- | Network awareness | Separate browser event wiring | `useOnlineStatus` for online/offline state |
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@^18.3.0 || ^19.0.0`
63
+ - `react@^19.0.0`
64
64
 
65
65
  ## How It Feels
66
66
 
67
67
  ```tsx
68
- import { useOnlineStatus, useWebSocket } from "react-realtime-hooks";
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
- Network: {network.isOnline ? "online" : "offline"} | Transport:{" "}
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` | Error callback |
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` | Error callback |
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((current) => ({
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