react-ws-context 0.3.0 → 0.4.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/CHANGELOG.md CHANGED
@@ -7,6 +7,39 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
7
7
 
8
8
  ## [Unreleased]
9
9
 
10
+ ## [0.4.1] - 2026-08-30
11
+
12
+ ### Added
13
+
14
+ - Export `LivenessOptions` from the package entry
15
+
16
+ ### Changed
17
+
18
+ - README (EN / zh-TW): align structure and wording; fix immutable-config URL guidance; clarify render isolation, stall parsing, and `WsProvider` lifecycle
19
+ - JSDoc (`CreateWsContextOptions`, `WsContextValue`): clearer field descriptions; fix `autoConnect` wording
20
+
21
+ ## [0.4.0] - 2026-08-29
22
+
23
+ ### Fixed
24
+
25
+ - Store `setState` skips listener notification when partial values are unchanged (shallow compare on updated keys)
26
+ - Provider unmount syncs store to `status: "closed"` and `phase: "idle"` (consistent with `disconnect()`)
27
+ - Liveness ping and timeout both use `getActiveSocket()` for the active socket
28
+ - `sendJson` returns `false` when `JSON.stringify` fails (e.g. circular reference) instead of throwing
29
+ - `useWsEvents` handler ref sync moved into `useEffect` (complies with `react-hooks/refs`)
30
+
31
+ ### Changed
32
+
33
+ - Shared `teardown()` for `disconnect()` and provider unmount cleanup
34
+ - On unintentional close, store updates before `close` is emitted so handlers see consistent `status` / `phase`
35
+ - Batch `status` and `phase` store updates where both change together
36
+ - `Liveness.start()` takes no socket argument; uses `getActiveSocket()` at runtime
37
+ - Internal layout: `useWsEventsApi` in `ws-events.ts` (`emitter.ts` is React-free); aligned Context module structure (`ws-store`, `ws-events`, `ws-actions`)
38
+
39
+ ### Added
40
+
41
+ - Store smoke test for `setState` deduplication
42
+
10
43
  ## [0.3.0] - 2026-08-29
11
44
 
12
45
  ### Added
package/README.md CHANGED
@@ -5,7 +5,7 @@
5
5
 
6
6
  > **繁體中文:** [README.zh-TW.md](./README.zh-TW.md)
7
7
 
8
- A React **WebSocket connection layer**. It separates connection lifecycle, subscribable state, and message events so status updates or high-frequency messages do not re-render your entire component tree.
8
+ A React **WebSocket connection-layer** package. It separates connection lifecycle, subscribable state, and message events so connection status or high-frequency messages do not re-render your entire component tree.
9
9
 
10
10
  > **Maintainer:** [GaiaYang](https://github.com/GaiaYang)
11
11
  > **Source:** [github.com/GaiaYang/react-ws](https://github.com/GaiaYang/react-ws) (package path: `packages/react-ws`)
@@ -14,7 +14,7 @@ A React **WebSocket connection layer**. It separates connection lifecycle, subsc
14
14
 
15
15
  - **Zero runtime dependencies** — only `react >= 18` as a peer dependency
16
16
  - **Frozen config** — `url`, `reconnectMs`, etc. are fixed at `createWsContext`; use `connect` / `disconnect` at runtime
17
- - **Render isolation** — connection-layer state (health / queue / reconnect) lives in an external store; messages go through an event emitter, not React Context
17
+ - **Render isolation** — connection-layer state (health / queue / reconnect) lives in an external store; messages are delivered through an event emitter and subscribed via `useWsEvents` (not written into React state)
18
18
  - **Optional liveness** — periodic ping / pong; closes the socket on timeout to trigger reconnect
19
19
  - **Optional outbound queue** — buffers messages while not OPEN, flushes on connect
20
20
 
@@ -87,12 +87,13 @@ function Chat() {
87
87
  createWsContext(options)
88
88
 
89
89
  ├── WsProvider WebSocket instance, reconnect, liveness, outbound queue
90
- ├── useWsActions() send / connect / disconnect — no re-renders
90
+ ├── useWsActions() send / connect / disconnect / getStatus — no re-renders
91
91
  ├── useWsStore() connection-layer state: health / queue / reconnect
92
92
  └── useWsEvents() open / message / error / close
93
93
  ```
94
94
 
95
95
  - **Call `createWsContext` multiple times** for independent connections (e.g. app WS + notification WS).
96
+ - **Provider internals** — each `WsProvider` owns a separate store and event emitter (not public API); actions are assembled in `WsProvider` via `useMemo` and passed through Context.
96
97
  - **`WsState` holds low-frequency connection data only** — health (`status`, `phase`), outbound queue (e.g. future `pendingCount`), reconnect (`reconnectAttempt`). **Not** message payloads or app data.
97
98
  - **Messages and errors** — use `useWsEvents`; keep message history in your own state, cache, or store.
98
99
  - **Connection errors are not a `WsStatus`** — use `useWsEvents("error")`; native `error` is usually followed by `close`.
@@ -107,16 +108,16 @@ Creates a `WsProvider` and hooks bound to the same connection config.
107
108
 
108
109
  #### `CreateWsContextOptions`
109
110
 
110
- | Field | Type | Default | Description |
111
- | ------------------ | ----------------------------------------- | ---------- | ----------------------------------------------------------------------------------------------------------------------------- |
112
- | `url` | `string` | (required) | WebSocket URL |
113
- | `protocols` | `string \| string[]` | — | Passed to `new WebSocket(url, protocols)` |
114
- | `autoConnect` | `boolean` | `true` | Call `connect()` after `WsProvider` mounts |
115
- | `reconnectMs` | `number` | `0` | Reconnect delay (ms) after unintentional close; `0` disables reconnect |
116
- | `reconnectMax` | `number` | `0` | Max auto-reconnects after unintentional close (excludes initial connect); `0` unlimited. `reconnectAttempt` resets on open, manual `connect()`, or `disconnect()` |
117
- | `outgoingQueueMax` | `number` | `0` | Max outbound queue size while not OPEN; `0` disables the queue |
118
- | `parse` | `(data: MessageEvent["data"]) => unknown` | see below | Transform raw `MessageEvent.data` |
119
- | `liveness` | `LivenessOptions` | — | Liveness / heartbeat config; omit to disable |
111
+ | Field | Type | Default | Description |
112
+ | ------------------ | ----------------------------------------- | ---------- | ----------------------------------------------------------------------------------------- |
113
+ | `url` | `string` | (required) | WebSocket URL |
114
+ | `protocols` | `string \| string[]` | — | Passed to `new WebSocket(url, protocols)` |
115
+ | `autoConnect` | `boolean` | `true` | Auto-connect when `WsProvider` loads |
116
+ | `reconnectMs` | `number` | `0` | Reconnect delay (ms) after unintentional close; `0` disables reconnect |
117
+ | `reconnectMax` | `number` | `0` | Max auto-reconnects after unintentional close; `0` unlimited (requires `reconnectMs > 0`) |
118
+ | `outgoingQueueMax` | `number` | `0` | Max outbound queue size while not OPEN; `0` disables the queue |
119
+ | `parse` | `(data: MessageEvent["data"]) => unknown` | see below | Transform raw `MessageEvent.data` |
120
+ | `liveness` | `LivenessOptions` | — | Liveness / heartbeat config; omit to disable |
120
121
 
121
122
  **Default `parse`:**
122
123
 
@@ -138,12 +139,13 @@ Creates a `WsProvider` and hooks bound to the same connection config.
138
139
 
139
140
  Creates, owns, and tears down the native `WebSocket`.
140
141
 
141
- | Behavior | Description |
142
- | --------------------------- | --------------------------------------------------------------------------------------------------------------------------------- |
143
- | mount + `autoConnect: true` | Calls `connect()` |
144
- | unmount | Closes connection, stops liveness, clears outbound queue, emits `close` |
145
- | reconnect | Fixed interval when `reconnectMs > 0` and close was not intentional (no exponential backoff); stops after `reconnectMax` if `> 0` |
146
- | before reconnect | Closes existing socket and emits `close` (reason: `"reconnect"`) |
142
+ | Behavior | Description |
143
+ | ---------------------------------------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
144
+ | `WsProvider` loads + `autoConnect: true` | Auto-connects |
145
+ | unmount | Cancels reconnect (`reconnectAttempt` and `reconnectExhausted` reset), stops liveness, clears outbound queue; syncs store to `status: "closed"`, `phase: "idle"`; closes socket and emits `close` (reason: `"provider unmount"`) |
146
+ | `disconnect()` | Same cleanup and store reset as unmount, no auto-reconnect; emits `close` (reason: `"client disconnect"`) |
147
+ | reconnect | Fixed interval when `reconnectMs > 0` and close was not intentional (no exponential backoff); stops after `reconnectMax` if `> 0` |
148
+ | `connect()` with existing socket | Closes the previous socket and emits `close` (reason: `"reconnect"`) before opening a new one (manual connect or auto-reconnect) |
147
149
 
148
150
  ---
149
151
 
@@ -151,27 +153,29 @@ Creates, owns, and tears down the native `WebSocket`.
151
153
 
152
154
  Must be used inside the matching `WsProvider`. Return value is memoized and **does not** re-render on store or message updates.
153
155
 
154
- | Method | Signature | Description |
155
- | ------------ | ---------------------------- | ---------------------------------------------------------------------------- |
156
- | `send` | `(data) => boolean` | Send raw data. Sends immediately when OPEN; otherwise enqueues if configured |
157
- | `sendJson` | `(data: unknown) => boolean` | `JSON.stringify` then `send` |
158
- | `connect` | `() => void` | Open connection; closes any existing socket first |
159
- | `disconnect` | `() => void` | Intentional close; no auto-reconnect; clears outbound queue |
160
- | `getStatus` | `() => WsStatus` | Read current status; no subscription, no re-render |
156
+ | Method | Signature | Description |
157
+ | ------------ | ---------------------------- | -------------------------------------------------------------------------------------------------------------------- |
158
+ | `send` | `(data) => boolean` | Send raw data (`string`, `ArrayBuffer`, `Blob`, etc.). Sends immediately when OPEN; otherwise enqueues if configured |
159
+ | `sendJson` | `(data: unknown) => boolean` | `JSON.stringify` then `send`; same return semantics as `send`; `false` if not serializable |
160
+ | `connect` | `() => void` | Open connection; closes any existing socket first (see `WsProvider`) |
161
+ | `disconnect` | `() => void` | Intentional close; sets store to `phase: "idle"`, `status: "closed"`; no auto-reconnect; clears outbound queue |
162
+ | `getStatus` | `() => WsStatus` | Read current status; no subscription, no re-render |
161
163
 
162
164
  **`send` / `sendJson` return value:**
163
165
 
164
166
  - `true` — sent or enqueued
165
- - `false` — not OPEN and queue full (`outgoingQueueMax > 0`), or queue disabled (`outgoingQueueMax === 0`)
167
+ - `false` — not sent: queue full, queue disabled, or `sendJson` could not serialize
166
168
 
167
169
  ---
168
170
 
169
171
  ### `useWsStore()`
170
172
 
171
- Must be used inside the matching `WsProvider`. Uses `useSyncExternalStore` under the hood.
173
+ Must be used inside the matching `WsProvider`. Uses `useSyncExternalStore` under the hood; **partial updates with unchanged values do not notify subscribers** (shallow compare).
172
174
 
173
175
  `WsState` is for **connection health / outbound queue / reconnect** — low-frequency lifecycle data. For high-frequency messages, use `useWsEvents("message", …)`, not the store.
174
176
 
177
+ **Tip:** use a selector to subscribe only to the fields you need (e.g. `(s) => s.phase`). `useWsStore()` without a selector subscribes to the full state — any field change triggers a re-render.
178
+
175
179
  ```ts
176
180
  useWsStore(): WsState
177
181
  useWsStore<T>(selector: (state: WsState) => T): T
@@ -193,9 +197,9 @@ interface WsState {
193
197
  }
194
198
  ```
195
199
 
196
- | Belongs in store | Does not belong |
197
- | ----------------------------------------------------------------------------------------------------------------------------- | -------------------------------------------- |
198
- | `status`, `phase`, reconnect progress (`reconnectAttempt` / `reconnectExhausted`), pending queue size, liveness / stall summaries | `lastMessage`, message history, app payloads |
200
+ | Belongs in store | Does not belong |
201
+ | ------------------------------------------------------------------------------------------------------------- | -------------------------------------------- |
202
+ | `status`, `phase`, reconnect progress (`reconnectAttempt` / `reconnectExhausted`), liveness / stall summaries | `lastMessage`, message history, app payloads |
199
203
 
200
204
  `CreateWsContextOptions` (e.g. `url`, `reconnectMax`) are frozen at `createWsContext` and are **not** in `WsState`. For UI like `n/max`, keep the config alongside the store fields you subscribe to.
201
205
 
@@ -212,17 +216,17 @@ Maps to the current WebSocket connection state (similar to readyState). Does **n
212
216
 
213
217
  #### `WsPhase`
214
218
 
215
- | Value | Meaning |
216
- | --------------- | ------------------------------------------------------------------------------------------------ |
217
- | `idle` | Not connected, no reconnect scheduled (initial or manual `disconnect()`) |
218
- | `connecting` | First connect or manual `connect()` in progress |
219
- | `open` | Connected |
220
- | `reconnecting` | Auto-reconnect cycle (waiting for timer or connecting); pair with `status`, `reconnectAttempt` |
221
- | `stopped` | Will not auto-reconnect; use `reconnectExhausted` to distinguish max retries vs reconnect disabled |
219
+ | Value | Meaning |
220
+ | -------------- | -------------------------------------------------------------------------------------------------- |
221
+ | `idle` | Not connected, no reconnect scheduled (initial or manual `disconnect()`) |
222
+ | `connecting` | First connect or manual `connect()` in progress |
223
+ | `open` | Connected |
224
+ | `reconnecting` | Auto-reconnect cycle (waiting for timer or connecting); pair with `status`, `reconnectAttempt` |
225
+ | `stopped` | Will not auto-reconnect; use `reconnectExhausted` to distinguish max retries vs reconnect disabled |
222
226
 
223
227
  `status` and `phase` often change together but mean different things. For example, `phase === "reconnecting"` with `status === "closed"` means waiting for the reconnect timer; `status === "connecting"` means the timer fired and a connect attempt is in progress.
224
228
 
225
- **Tip:** use a selector to subscribe to only the fields you need.
229
+ **Example:**
226
230
 
227
231
  ```tsx
228
232
  const phase = useWsStore((s) => s.phase);
@@ -252,20 +256,24 @@ Must be used inside the matching `WsProvider`. Registers in `useEffect` and unsu
252
256
 
253
257
  - Handler is kept in a ref — changing the callback does **not** re-subscribe
254
258
  - Changing `type` **does** re-subscribe
259
+ - On unintentional close, the store is updated to `status: "closed"` and the appropriate `phase` before the `close` handler runs
260
+ - Intentional `disconnect()` or provider unmount follows the same order: store first, then `close`
255
261
  - For multiple events, call `useWsEvents` multiple times
256
262
 
257
263
  ---
258
264
 
259
- ### Liveness: `LivenessOptions`
265
+ ### Liveness
266
+
267
+ Enable via `createWsContext({ liveness: { … } })`. After OPEN, sends periodic application-layer pings (JSON via `send`, not WebSocket control frames); if no matching pong within `timeoutMs`, closes the socket (which can trigger reconnect).
260
268
 
261
- Enable via `createWsContext({ liveness: { … } })`. After OPEN, sends periodic pings; if no matching pong within `timeoutMs`, closes the socket (which can trigger reconnect).
269
+ Shape of the `liveness` option:
262
270
 
263
271
  ```ts
264
272
  interface LivenessOptions {
265
- intervalMs: number;
266
- timeoutMs: number;
267
- ping: unknown | (() => unknown);
268
- isPong: (data: unknown) => boolean;
273
+ intervalMs: number; // ping interval (ms)
274
+ timeoutMs: number; // wait for pong (ms)
275
+ ping: unknown | (() => unknown); // ping payload; function for dynamic values
276
+ isPong: (data: unknown) => boolean; // whether parsed data is a pong
269
277
  }
270
278
  ```
271
279
 
@@ -287,7 +295,7 @@ createWsContext({
287
295
  });
288
296
  ```
289
297
 
290
- Every incoming message is checked with `isPong`; a pong resets the timeout timer and still emits `"message"`.
298
+ Every incoming message is checked with `isPong`; a pong clears the timeout timer and still emits `"message"`. Ping payloads are always sent via `JSON.stringify` (JSON only).
291
299
 
292
300
  ---
293
301
 
@@ -295,14 +303,14 @@ Every incoming message is checked with `isPong`; a pong resets the timeout timer
295
303
 
296
304
  When `outgoingQueueMax > 0`:
297
305
 
298
- | When | Behavior |
299
- | -------------------------- | ------------------------------------------------- |
300
- | `send` while not OPEN | Enqueue (FIFO) |
301
- | Queue full | Returns `false`; does **not** drop older messages |
302
- | Socket OPEN | Flush entire queue in order |
303
- | `disconnect()` | Clear queue |
304
- | `WsProvider` unmount | Clear queue |
305
- | Waiting for auto-reconnect | **Keep** queue |
306
+ | When | Behavior |
307
+ | -------------------------- | -------------------------------------------------------------- |
308
+ | `send` while not OPEN | Enqueue (FIFO) |
309
+ | Queue full | Returns `false`; does **not** drop older messages |
310
+ | Socket OPEN | Flush entire queue in order |
311
+ | `disconnect()` | Clear queue; store set to `idle` / `closed` (see `WsProvider`) |
312
+ | `WsProvider` unmount | Clear queue; store synced (see `WsProvider`) |
313
+ | Waiting for auto-reconnect | **Keep** queue |
306
314
 
307
315
  ---
308
316
 
@@ -310,14 +318,15 @@ When `outgoingQueueMax > 0`:
310
318
 
311
319
  From the main `react-ws-context` entry:
312
320
 
313
- | Type | Description |
314
- | ------------------------ | ----------------------------------------------------- |
315
- | `CreateWsContextOptions` | Options for `createWsContext` |
316
- | `WsContextValue` | Return type of `useWsActions()` |
317
- | `WsEvents` | Event name handler map |
318
- | `WsStatus` | WebSocket connection state (`WsState`) |
321
+ | Type | Description |
322
+ | ------------------------ | -------------------------------------------------------- |
323
+ | `CreateWsContextOptions` | Options for `createWsContext` |
324
+ | `LivenessOptions` | Options for `liveness` in `createWsContext` |
325
+ | `WsContextValue` | Return type of `useWsActions()` |
326
+ | `WsEvents` | Event name handler map |
327
+ | `WsStatus` | WebSocket connection state (`WsState`) |
319
328
  | `WsPhase` | Provider connection intent / reconnect phase (`WsState`) |
320
- | `WsState` | Subscribable store shape (health / queue / reconnect) |
329
+ | `WsState` | Subscribable store shape (health / queue / reconnect) |
321
330
 
322
331
  ---
323
332
 
@@ -337,28 +346,42 @@ import {
337
346
  } from "react-ws-context/stall";
338
347
  ```
339
348
 
340
- | Export | Description |
341
- | ---------------------------- | ------------------------------------------------------------- |
342
- | `STALL_MESSAGE_TYPE` | Client control message type (`"STALL"`) |
343
- | `STALL_ACK_TYPE` | Server ack type (`"STALL_ACK"`) |
344
- | `createStallMessage(action)` | Build a message for `sendJson` |
345
- | `parseStallMessage(data)` | Parse from `useWsEvents("message")` data; `null` if invalid |
346
- | `StallAction` | `"stall" \| "release"` |
347
- | `StallMessage` | `{ type: "STALL"; action: StallAction }` |
348
- | `StallAck` | `{ type: "STALL_ACK"; action: StallAction; active: boolean }` |
349
+ | Export | Description |
350
+ | ---------------------------- | ----------------------------------------------------------------------------------------------------- |
351
+ | `STALL_MESSAGE_TYPE` | Client control message type (`"STALL"`) |
352
+ | `STALL_ACK_TYPE` | Server ack type (`"STALL_ACK"`) — for typing only; no built-in parser |
353
+ | `createStallMessage(action)` | Build a client `STALL` message for `sendJson` |
354
+ | `parseStallMessage(data)` | Parse client `STALL` messages from `useWsEvents("message")` data; `null` if invalid (not `STALL_ACK`) |
355
+ | `StallAction` | `"stall" \| "release"` |
356
+ | `StallMessage` | `{ type: "STALL"; action: StallAction }` |
357
+ | `StallAck` | `{ type: "STALL_ACK"; action: StallAction; active: boolean }` — parse server acks yourself |
358
+
359
+ **Example:**
360
+
361
+ ```tsx
362
+ const { sendJson } = useWsActions();
363
+
364
+ useWsEvents("message", (data) => {
365
+ const stall = parseStallMessage(data);
366
+ if (stall) console.log("stall control", stall.action);
367
+ });
368
+
369
+ sendJson(createStallMessage("stall"));
370
+ ```
349
371
 
350
372
  ---
351
373
 
352
374
  ## Design trade-offs
353
375
 
354
- | Topic | Notes |
355
- | ---------------- | ------------------------------------------------------------------------------ |
356
- | Immutable config | `url`, `reconnectMs`, etc. are fixed at create time |
357
- | Reconnect | Fixed interval only; no exponential backoff; optional cap via `reconnectMax` |
358
- | SSR | No `WebSocket` on the server; `connect()` is a no-op without `window` |
359
- | Error status | No `"error"` in `WsStatus`; use `useWsEvents("error")` |
360
- | `WsState` scope | Health / queue / reconnect only — not messages or app data |
361
- | Rendering | Components that only call `useWsActions` do not re-render on store or messages |
376
+ | Topic | Notes |
377
+ | ---------------- | --------------------------------------------------------------------------------------------------------- |
378
+ | Immutable config | `url`, `reconnectMs`, etc. are fixed at create time; to use a different URL, call `createWsContext` again |
379
+ | Reconnect | Fixed interval only; no exponential backoff; optional cap via `reconnectMax` |
380
+ | SSR | No `WebSocket` on the server; `connect()` is a no-op without `window` |
381
+ | Error status | No `"error"` in `WsStatus`; use `useWsEvents("error")` |
382
+ | `WsState` scope | Health / queue / reconnect only — not messages or app data |
383
+ | Rendering | Components that only call `useWsActions` do not re-render on store or messages |
384
+ | Store updates | Repeated writes of the same field values do not notify; prefer selectors |
362
385
 
363
386
  ---
364
387
 
@@ -377,13 +400,13 @@ This package does **not** list zustand or nanoevents as npm dependencies. It inl
377
400
  - **Maintainer:** [pmndrs](https://github.com/pmndrs) (Poimandres)
378
401
  - **License:** [MIT](https://github.com/pmndrs/zustand/blob/main/LICENSE)
379
402
  - **Adapted from:**
380
- - External store API — aligned with [`vanilla.ts`](https://github.com/pmndrs/zustand/blob/main/src/vanilla.ts) (subset only)
381
- - React subscription — inspired by [`react.ts`](https://github.com/pmndrs/zustand/blob/main/src/react.ts) `useStore`
403
+ - External store API — aligned with [`vanilla.ts`](https://github.com/pmndrs/zustand/blob/main/src/vanilla.ts) (subset only; no middleware, replace, or initializer factory); `setState` adds partial shallow dedup (unchanged values skip notification)
404
+ - React subscription — inspired by [`react.ts`](https://github.com/pmndrs/zustand/blob/main/src/react.ts) `useStore` (no `useDebugValue`); optional selector overload lives in `createUseWsStore`
382
405
  - **Files:** `src/ws-context/store.ts`, `src/ws-context/use-store.ts`
383
406
 
384
407
  ### [nanoevents](https://github.com/ai/nanoevents)
385
408
 
386
409
  - **Author:** [Andrey Sitnik](https://github.com/ai) (`ai`)
387
410
  - **License:** [MIT](https://github.com/ai/nanoevents/blob/main/LICENSE)
388
- - **Adapted from:** [`createNanoEvents`](https://github.com/ai/nanoevents/blob/main/index.js); `useEmitter` added by this package
389
- - **File:** `src/ws-context/emitter.ts`
411
+ - **Adapted from:** [`createNanoEvents`](https://github.com/ai/nanoevents/blob/main/index.js); React subscription wrapper added in `ws-events.ts`
412
+ - **Files:** `src/ws-context/emitter.ts`, `src/ws-context/ws-events.ts`