floppy-disk 3.7.1-beta.1 → 3.7.2

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.
@@ -33,6 +33,50 @@ type StreamDataState<TData, TError> = {
33
33
  error: TError;
34
34
  errorUpdatedAt: number;
35
35
  };
36
+ /**
37
+ * Represents the full state of a stream.
38
+ *
39
+ * @remarks
40
+ * A stream consists of two independent concerns:
41
+ *
42
+ * 1. **Connection state** — lifecycle of the underlying connection
43
+ * 2. **Data state** — lifecycle of emitted data
44
+ *
45
+ * These two are combined into a single state object.
46
+ *
47
+ * ---
48
+ *
49
+ * ## Connection lifecycle
50
+ *
51
+ * - `INITIAL` → no connection has been established
52
+ * - `CONNECTING` → connection is being established
53
+ * - `CONNECTED` → connection is active
54
+ * - `DISCONNECTED` → connection was previously established but is now closed
55
+ *
56
+ * Timestamps:
57
+ * - `connectingAt` → when connection attempt started
58
+ * - `connectedAt` → when connection was established
59
+ * - `disconnectedAt` → when connection was closed
60
+ *
61
+ * ---
62
+ *
63
+ * ## Data lifecycle
64
+ *
65
+ * - `INITIAL` → no data has been received
66
+ * - `SUCCESS` → data has been received successfully
67
+ * - `ERROR` → error occurred before any data
68
+ * - `SUCCESS_BUT_THEN_ERROR` → data exists, but a later error occurred
69
+ *
70
+ * ---
71
+ *
72
+ * ## Notes
73
+ *
74
+ * - Connection state and data state evolve independently.
75
+ * - A stream may be:
76
+ * - connected but have no data yet
77
+ * - disconnected but still retain previous data
78
+ * - Errors do not necessarily reset data.
79
+ */
36
80
  export type StreamState<TData, TError> = ({
37
81
  connectionState: "INITIAL";
38
82
  connectingAt: undefined;
@@ -43,8 +87,8 @@ export type StreamState<TData, TError> = ({
43
87
  }>) | ({
44
88
  connectionState: "CONNECTING";
45
89
  connectingAt: number;
46
- connectedAt: number | undefined;
47
- disconnectedAt: number | undefined;
90
+ connectedAt: undefined;
91
+ disconnectedAt: undefined;
48
92
  } & StreamDataState<TData, TError>) | ({
49
93
  connectionState: "CONNECTED";
50
94
  connectingAt: number;
@@ -60,25 +104,218 @@ type DisconnectTrigger = "last-unsubscribe" | "document-hidden" | "offline";
60
104
  type ReconnectTrigger = "first-subscribe" | "document-visible" | "online";
61
105
  type AdditionalStoreApi<TConnection> = {
62
106
  variableHash: string;
107
+ /**
108
+ * Connection controls for the stream.
109
+ *
110
+ * @remarks
111
+ * Provides imperative control over the underlying connection.
112
+ */
63
113
  connection: {
114
+ /**
115
+ * Returns the current connection instance.
116
+ *
117
+ * @returns The active connection or `undefined` if not connected
118
+ */
64
119
  get: () => Readonly<TConnection> | undefined;
120
+ /**
121
+ * Forces a reconnection.
122
+ *
123
+ * @remarks
124
+ * - Cancels any scheduled disconnect
125
+ * - Starts a new connection if not already connecting
126
+ */
65
127
  reconnect: () => void;
128
+ /**
129
+ * Immediately disconnects the current connection.
130
+ *
131
+ * @remarks
132
+ * - Ignores disconnect delay rules
133
+ * - Updates connection state to `DISCONNECTED`
134
+ */
66
135
  disconnect: () => void;
67
136
  };
137
+ /**
138
+ * Data controls for the stream.
139
+ */
68
140
  data: {
141
+ /**
142
+ * Resets the data state back to `INITIAL`.
143
+ *
144
+ * @remarks
145
+ * - Does not affect connection state
146
+ * - Useful for clearing stale or invalid data
147
+ */
69
148
  reset: () => void;
70
149
  };
150
+ /**
151
+ * Deletes the stream instance.
152
+ *
153
+ * @returns `true` if deleted, `false` otherwise
154
+ *
155
+ * @remarks
156
+ * - Cannot delete while there are active subscribers
157
+ * - Clears connection, state, and cached instance
158
+ */
71
159
  delete: () => boolean;
72
160
  };
161
+ /**
162
+ * Configuration options for a stream.
163
+ *
164
+ * @remarks
165
+ * Controls connection lifecycle, reconnection behavior, and data retention.
166
+ */
73
167
  export type StreamOptions<TConnection, TData, TError = Error> = InitStoreOptions<StreamState<TData, TError>, AdditionalStoreApi<TConnection>> & {
168
+ /**
169
+ * Connection-related behavior.
170
+ */
74
171
  connection?: {
172
+ /**
173
+ * Determines when a connection should be disconnected.
174
+ *
175
+ * @param trigger - The reason for the disconnect attempt
176
+ * @param state - Current stream state
177
+ *
178
+ * @returns
179
+ * - `number` → delay (ms) before disconnecting
180
+ * - `false` → prevent disconnection
181
+ *
182
+ * @default Disconnect after 5 seconds for any triggers
183
+ *
184
+ * @remarks
185
+ * Triggers:
186
+ * - `"last-unsubscribe"` → no active subscribers
187
+ * - `"document-hidden"` → tab becomes hidden
188
+ * - `"offline"` → network goes offline
189
+ */
75
190
  disconnectOn?: (trigger: DisconnectTrigger, state: StreamState<TData, TError>) => false | number;
191
+ /**
192
+ * Determines whether a connection should reconnect.
193
+ *
194
+ * @param trigger - The reason for the reconnect attempt
195
+ * @param state - Current stream state
196
+ *
197
+ * @returns `true` to reconnect, otherwise `false`
198
+ *
199
+ * @default No reconnection if already connected
200
+ *
201
+ * @remarks
202
+ * Triggers:
203
+ * - `"first-subscribe"` → first subscriber appears
204
+ * - `"document-visible"` → tab becomes visible
205
+ * - `"online"` → network reconnects
206
+ */
76
207
  reconnectOn?: (trigger: ReconnectTrigger, state: StreamState<TData, TError>) => boolean;
77
208
  };
209
+ /**
210
+ * Data-related behavior.
211
+ */
78
212
  data?: {
213
+ /**
214
+ * Time (in milliseconds) before unused stream data is garbage collected.
215
+ *
216
+ * Starts counting after disconnection.
217
+ *
218
+ * @default 5 minutes
219
+ */
79
220
  gcTime?: number;
80
221
  };
81
222
  };
223
+ /**
224
+ * Creates a stream factory for managing real-time connections.
225
+ *
226
+ * @param connect - Function to establish a connection
227
+ * @param disconnect - Function to close a connection
228
+ * @param options - Optional configuration for lifecycle and behavior
229
+ *
230
+ * @returns A function to retrieve or create a stream instance by variable
231
+ *
232
+ * @remarks
233
+ * This utility is designed for **long-lived, push-based async sources**, such as:
234
+ * - WebSocket
235
+ * - Server-Sent Events (SSE)
236
+ * - Firebase / realtime databases
237
+ *
238
+ * ---
239
+ *
240
+ * ## Key concepts
241
+ *
242
+ * ### 1. Connection lifecycle (managed automatically)
243
+ *
244
+ * - Connection is established when needed (e.g. first subscriber)
245
+ * - Connection may be disconnected based on triggers:
246
+ * - no subscribers
247
+ * - tab hidden
248
+ * - offline
249
+ * - Reconnection is controlled via `reconnectOn`
250
+ *
251
+ * ---
252
+ *
253
+ * ### 2. Data flow (push-based)
254
+ *
255
+ * The `connect` function receives an `emit` API:
256
+ *
257
+ * - `emit.connected()` → mark connection as established
258
+ * - `emit.data(fn)` → update data using reducer
259
+ * - `emit.error(err)` → report error
260
+ *
261
+ * Data updates are **incremental** and controlled by the stream source.
262
+ *
263
+ * ---
264
+ *
265
+ * ### 3. Store-per-variable
266
+ *
267
+ * - Each unique `variable` creates a separate stream instance
268
+ * - Variables are deterministically hashed for stable identity
269
+ * - Each instance manages its own:
270
+ * - connection
271
+ * - state
272
+ * - subscribers
273
+ *
274
+ * ---
275
+ *
276
+ * ### 4. React integration (Proxy-based)
277
+ *
278
+ * - The returned hook exposes the full state as a Proxy
279
+ * - Components automatically subscribe to accessed properties
280
+ * - No selector or memoization is required
281
+ *
282
+ * ---
283
+ *
284
+ * ## Execution model
285
+ *
286
+ * - Streams are **lazy**:
287
+ * - No connection until there is a subscriber
288
+ * - Streams are **shared**:
289
+ * - Multiple subscribers reuse the same connection
290
+ * - Streams are **stateful**:
291
+ * - Data persists across reconnects (unless reset or GC)
292
+ *
293
+ * ---
294
+ *
295
+ * @example
296
+ * const chatStream = createStream(
297
+ * (roomId, emit) => {
298
+ * const ws = new WebSocket(`/chat/${roomId}`);
299
+ *
300
+ * ws.onopen = () => emit.connected();
301
+ * ws.onmessage = (e) => {
302
+ * const msg = JSON.parse(e.data);
303
+ * emit.data((prev) => [...(prev ?? []), msg]);
304
+ * };
305
+ * ws.onerror = (err) => emit.error(err);
306
+ *
307
+ * return ws;
308
+ * },
309
+ * (ws) => ws.close()
310
+ * );
311
+ *
312
+ * function Chat({ roomId }) {
313
+ * const useChat = chatStream(roomId);
314
+ * const state = useChat();
315
+ *
316
+ * return <div>{state.data?.length}</div>;
317
+ * }
318
+ */
82
319
  export declare const experimental_createStream: <TConnection, TData, TVariable extends StoreKey, TError = Error>(connect: (variable: TVariable, emit: {
83
320
  connected: () => void;
84
321
  data: (reducer: (data: TData | undefined) => TData) => void;
@@ -91,14 +328,58 @@ export declare const experimental_createStream: <TConnection, TData, TVariable e
91
328
  subscribe: (subscriber: import("../vanilla.d.mts").Subscriber<StreamState<TData, TError>>) => () => void;
92
329
  getSubscriberCount: () => number;
93
330
  variableHash: string;
331
+ /**
332
+ * Connection controls for the stream.
333
+ *
334
+ * @remarks
335
+ * Provides imperative control over the underlying connection.
336
+ */
94
337
  connection: {
338
+ /**
339
+ * Returns the current connection instance.
340
+ *
341
+ * @returns The active connection or `undefined` if not connected
342
+ */
95
343
  get: () => Readonly<TConnection> | undefined;
344
+ /**
345
+ * Forces a reconnection.
346
+ *
347
+ * @remarks
348
+ * - Cancels any scheduled disconnect
349
+ * - Starts a new connection if not already connecting
350
+ */
96
351
  reconnect: () => void;
352
+ /**
353
+ * Immediately disconnects the current connection.
354
+ *
355
+ * @remarks
356
+ * - Ignores disconnect delay rules
357
+ * - Updates connection state to `DISCONNECTED`
358
+ */
97
359
  disconnect: () => void;
98
360
  };
361
+ /**
362
+ * Data controls for the stream.
363
+ */
99
364
  data: {
365
+ /**
366
+ * Resets the data state back to `INITIAL`.
367
+ *
368
+ * @remarks
369
+ * - Does not affect connection state
370
+ * - Useful for clearing stale or invalid data
371
+ */
100
372
  reset: () => void;
101
373
  };
374
+ /**
375
+ * Deletes the stream instance.
376
+ *
377
+ * @returns `true` if deleted, `false` otherwise
378
+ *
379
+ * @remarks
380
+ * - Cannot delete while there are active subscribers
381
+ * - Clears connection, state, and cached instance
382
+ */
102
383
  delete: () => boolean;
103
384
  };
104
385
  export {};
package/esm/react.mjs CHANGED
@@ -785,6 +785,19 @@ const experimental_createStream = (connect, disconnect, options = {}) => {
785
785
  const connections = /* @__PURE__ */ new WeakMap();
786
786
  const disconnectFns = /* @__PURE__ */ new WeakMap();
787
787
  const disconnectTimeoutIds = /* @__PURE__ */ new WeakMap();
788
+ const clearAllTimeouts = (store) => {
789
+ const gcTimeoutId = gcTimeoutIds.get(store);
790
+ if (gcTimeoutId) {
791
+ clearTimeout(gcTimeoutId);
792
+ gcTimeoutIds.delete(store);
793
+ }
794
+ const disconnectTimeoutIds_ = disconnectTimeoutIds.get(store);
795
+ if (disconnectTimeoutIds_) {
796
+ clearTimeout(disconnectTimeoutIds_["last-unsubscribe"]);
797
+ clearTimeout(disconnectTimeoutIds_["document-hidden"]);
798
+ clearTimeout(disconnectTimeoutIds_.offline);
799
+ }
800
+ };
788
801
  const gcTimeoutIds = /* @__PURE__ */ new WeakMap();
789
802
  const configureStoreEvents = () => ({
790
803
  ...options,
@@ -827,12 +840,7 @@ const experimental_createStream = (connect, disconnect, options = {}) => {
827
840
  if (store.getSubscriberCount() && showLog) {
828
841
  console.log("Stream disconnected while there is subscriber");
829
842
  }
830
- const disconnectTimeoutIds_ = disconnectTimeoutIds.get(store);
831
- if (disconnectTimeoutIds_) {
832
- clearTimeout(disconnectTimeoutIds_["last-unsubscribe"]);
833
- clearTimeout(disconnectTimeoutIds_["document-hidden"]);
834
- clearTimeout(disconnectTimeoutIds_.offline);
835
- }
843
+ clearAllTimeouts(store);
836
844
  (_a = disconnectFns.get(store)) == null ? void 0 : _a();
837
845
  if (store.getState().connectionState !== "INITIAL") {
838
846
  store.setState({
@@ -845,8 +853,7 @@ const experimental_createStream = (connect, disconnect, options = {}) => {
845
853
  gcTimeoutIds.set(
846
854
  store,
847
855
  setTimeout(() => {
848
- if (store.getSubscriberCount()) store.data.reset();
849
- else store.delete();
856
+ if (store.getSubscriberCount() === 0) store.delete();
850
857
  }, gcTime)
851
858
  );
852
859
  };
@@ -866,13 +873,19 @@ const experimental_createStream = (connect, disconnect, options = {}) => {
866
873
  store.connection = {};
867
874
  store.connection.get = () => connections.get(store);
868
875
  store.connection.reconnect = () => {
869
- var _a;
876
+ clearAllTimeouts(store);
870
877
  const { connectionState } = store.getState();
871
878
  if (connectionState === "CONNECTING") return;
872
- (_a = disconnectFns.get(store)) == null ? void 0 : _a();
879
+ const prevDisconnect = disconnectFns.get(store);
880
+ if (prevDisconnect) {
881
+ prevDisconnect();
882
+ disconnectFns.delete(store);
883
+ }
873
884
  store.setState({
874
885
  connectionState: "CONNECTING",
875
- connectingAt: Date.now()
886
+ connectingAt: Date.now(),
887
+ connectedAt: void 0,
888
+ disconnectedAt: void 0
876
889
  });
877
890
  const connection = connect(variable, {
878
891
  connected: () => {
@@ -884,10 +897,10 @@ const experimental_createStream = (connect, disconnect, options = {}) => {
884
897
  },
885
898
  data: (reducer) => {
886
899
  store.setState((prev) => {
887
- var _a2;
900
+ var _a;
888
901
  return {
889
902
  connectionState: "CONNECTED",
890
- connectedAt: (_a2 = prev.connectedAt) != null ? _a2 : Date.now(),
903
+ connectedAt: (_a = prev.connectedAt) != null ? _a : Date.now(),
891
904
  state: "SUCCESS",
892
905
  isSuccess: true,
893
906
  isError: false,
@@ -930,6 +943,7 @@ const experimental_createStream = (connect, disconnect, options = {}) => {
930
943
  );
931
944
  return false;
932
945
  }
946
+ clearAllTimeouts(store);
933
947
  store.setState(initialState);
934
948
  return stores.delete(variableHash);
935
949
  };
@@ -946,16 +960,13 @@ const experimental_createStream = (connect, disconnect, options = {}) => {
946
960
  });
947
961
  };
948
962
  const triggerReconnect = (store, trigger) => {
949
- clearTimeout(gcTimeoutIds.get(store));
950
- const disconnectTimeoutIds_ = disconnectTimeoutIds.get(store);
951
- if (disconnectTimeoutIds_) {
952
- clearTimeout(disconnectTimeoutIds_["last-unsubscribe"]);
953
- clearTimeout(disconnectTimeoutIds_["document-hidden"]);
954
- clearTimeout(disconnectTimeoutIds_.offline);
955
- }
963
+ clearAllTimeouts(store);
956
964
  const { connectionState } = store.getState();
957
965
  if (connectionState === "INITIAL" || connectionState === "DISCONNECTED") {
958
- return store.connection.reconnect();
966
+ queueMicrotask(() => {
967
+ store.connection.reconnect();
968
+ });
969
+ return;
959
970
  }
960
971
  const shouldReconnect = reconnectOn(trigger, store.getState());
961
972
  if (shouldReconnect) store.connection.reconnect();
package/package.json CHANGED
@@ -2,10 +2,7 @@
2
2
  "name": "floppy-disk",
3
3
  "description": "Lightweight unified state management for sync and async data.",
4
4
  "private": false,
5
- "version": "3.7.1-beta.1",
6
- "publishConfig": {
7
- "tag": "beta"
8
- },
5
+ "version": "3.7.2",
9
6
  "keywords": [
10
7
  "utilities",
11
8
  "store",
@@ -33,6 +33,50 @@ type StreamDataState<TData, TError> = {
33
33
  error: TError;
34
34
  errorUpdatedAt: number;
35
35
  };
36
+ /**
37
+ * Represents the full state of a stream.
38
+ *
39
+ * @remarks
40
+ * A stream consists of two independent concerns:
41
+ *
42
+ * 1. **Connection state** — lifecycle of the underlying connection
43
+ * 2. **Data state** — lifecycle of emitted data
44
+ *
45
+ * These two are combined into a single state object.
46
+ *
47
+ * ---
48
+ *
49
+ * ## Connection lifecycle
50
+ *
51
+ * - `INITIAL` → no connection has been established
52
+ * - `CONNECTING` → connection is being established
53
+ * - `CONNECTED` → connection is active
54
+ * - `DISCONNECTED` → connection was previously established but is now closed
55
+ *
56
+ * Timestamps:
57
+ * - `connectingAt` → when connection attempt started
58
+ * - `connectedAt` → when connection was established
59
+ * - `disconnectedAt` → when connection was closed
60
+ *
61
+ * ---
62
+ *
63
+ * ## Data lifecycle
64
+ *
65
+ * - `INITIAL` → no data has been received
66
+ * - `SUCCESS` → data has been received successfully
67
+ * - `ERROR` → error occurred before any data
68
+ * - `SUCCESS_BUT_THEN_ERROR` → data exists, but a later error occurred
69
+ *
70
+ * ---
71
+ *
72
+ * ## Notes
73
+ *
74
+ * - Connection state and data state evolve independently.
75
+ * - A stream may be:
76
+ * - connected but have no data yet
77
+ * - disconnected but still retain previous data
78
+ * - Errors do not necessarily reset data.
79
+ */
36
80
  export type StreamState<TData, TError> = ({
37
81
  connectionState: "INITIAL";
38
82
  connectingAt: undefined;
@@ -43,8 +87,8 @@ export type StreamState<TData, TError> = ({
43
87
  }>) | ({
44
88
  connectionState: "CONNECTING";
45
89
  connectingAt: number;
46
- connectedAt: number | undefined;
47
- disconnectedAt: number | undefined;
90
+ connectedAt: undefined;
91
+ disconnectedAt: undefined;
48
92
  } & StreamDataState<TData, TError>) | ({
49
93
  connectionState: "CONNECTED";
50
94
  connectingAt: number;
@@ -60,25 +104,218 @@ type DisconnectTrigger = "last-unsubscribe" | "document-hidden" | "offline";
60
104
  type ReconnectTrigger = "first-subscribe" | "document-visible" | "online";
61
105
  type AdditionalStoreApi<TConnection> = {
62
106
  variableHash: string;
107
+ /**
108
+ * Connection controls for the stream.
109
+ *
110
+ * @remarks
111
+ * Provides imperative control over the underlying connection.
112
+ */
63
113
  connection: {
114
+ /**
115
+ * Returns the current connection instance.
116
+ *
117
+ * @returns The active connection or `undefined` if not connected
118
+ */
64
119
  get: () => Readonly<TConnection> | undefined;
120
+ /**
121
+ * Forces a reconnection.
122
+ *
123
+ * @remarks
124
+ * - Cancels any scheduled disconnect
125
+ * - Starts a new connection if not already connecting
126
+ */
65
127
  reconnect: () => void;
128
+ /**
129
+ * Immediately disconnects the current connection.
130
+ *
131
+ * @remarks
132
+ * - Ignores disconnect delay rules
133
+ * - Updates connection state to `DISCONNECTED`
134
+ */
66
135
  disconnect: () => void;
67
136
  };
137
+ /**
138
+ * Data controls for the stream.
139
+ */
68
140
  data: {
141
+ /**
142
+ * Resets the data state back to `INITIAL`.
143
+ *
144
+ * @remarks
145
+ * - Does not affect connection state
146
+ * - Useful for clearing stale or invalid data
147
+ */
69
148
  reset: () => void;
70
149
  };
150
+ /**
151
+ * Deletes the stream instance.
152
+ *
153
+ * @returns `true` if deleted, `false` otherwise
154
+ *
155
+ * @remarks
156
+ * - Cannot delete while there are active subscribers
157
+ * - Clears connection, state, and cached instance
158
+ */
71
159
  delete: () => boolean;
72
160
  };
161
+ /**
162
+ * Configuration options for a stream.
163
+ *
164
+ * @remarks
165
+ * Controls connection lifecycle, reconnection behavior, and data retention.
166
+ */
73
167
  export type StreamOptions<TConnection, TData, TError = Error> = InitStoreOptions<StreamState<TData, TError>, AdditionalStoreApi<TConnection>> & {
168
+ /**
169
+ * Connection-related behavior.
170
+ */
74
171
  connection?: {
172
+ /**
173
+ * Determines when a connection should be disconnected.
174
+ *
175
+ * @param trigger - The reason for the disconnect attempt
176
+ * @param state - Current stream state
177
+ *
178
+ * @returns
179
+ * - `number` → delay (ms) before disconnecting
180
+ * - `false` → prevent disconnection
181
+ *
182
+ * @default Disconnect after 5 seconds for any triggers
183
+ *
184
+ * @remarks
185
+ * Triggers:
186
+ * - `"last-unsubscribe"` → no active subscribers
187
+ * - `"document-hidden"` → tab becomes hidden
188
+ * - `"offline"` → network goes offline
189
+ */
75
190
  disconnectOn?: (trigger: DisconnectTrigger, state: StreamState<TData, TError>) => false | number;
191
+ /**
192
+ * Determines whether a connection should reconnect.
193
+ *
194
+ * @param trigger - The reason for the reconnect attempt
195
+ * @param state - Current stream state
196
+ *
197
+ * @returns `true` to reconnect, otherwise `false`
198
+ *
199
+ * @default No reconnection if already connected
200
+ *
201
+ * @remarks
202
+ * Triggers:
203
+ * - `"first-subscribe"` → first subscriber appears
204
+ * - `"document-visible"` → tab becomes visible
205
+ * - `"online"` → network reconnects
206
+ */
76
207
  reconnectOn?: (trigger: ReconnectTrigger, state: StreamState<TData, TError>) => boolean;
77
208
  };
209
+ /**
210
+ * Data-related behavior.
211
+ */
78
212
  data?: {
213
+ /**
214
+ * Time (in milliseconds) before unused stream data is garbage collected.
215
+ *
216
+ * Starts counting after disconnection.
217
+ *
218
+ * @default 5 minutes
219
+ */
79
220
  gcTime?: number;
80
221
  };
81
222
  };
223
+ /**
224
+ * Creates a stream factory for managing real-time connections.
225
+ *
226
+ * @param connect - Function to establish a connection
227
+ * @param disconnect - Function to close a connection
228
+ * @param options - Optional configuration for lifecycle and behavior
229
+ *
230
+ * @returns A function to retrieve or create a stream instance by variable
231
+ *
232
+ * @remarks
233
+ * This utility is designed for **long-lived, push-based async sources**, such as:
234
+ * - WebSocket
235
+ * - Server-Sent Events (SSE)
236
+ * - Firebase / realtime databases
237
+ *
238
+ * ---
239
+ *
240
+ * ## Key concepts
241
+ *
242
+ * ### 1. Connection lifecycle (managed automatically)
243
+ *
244
+ * - Connection is established when needed (e.g. first subscriber)
245
+ * - Connection may be disconnected based on triggers:
246
+ * - no subscribers
247
+ * - tab hidden
248
+ * - offline
249
+ * - Reconnection is controlled via `reconnectOn`
250
+ *
251
+ * ---
252
+ *
253
+ * ### 2. Data flow (push-based)
254
+ *
255
+ * The `connect` function receives an `emit` API:
256
+ *
257
+ * - `emit.connected()` → mark connection as established
258
+ * - `emit.data(fn)` → update data using reducer
259
+ * - `emit.error(err)` → report error
260
+ *
261
+ * Data updates are **incremental** and controlled by the stream source.
262
+ *
263
+ * ---
264
+ *
265
+ * ### 3. Store-per-variable
266
+ *
267
+ * - Each unique `variable` creates a separate stream instance
268
+ * - Variables are deterministically hashed for stable identity
269
+ * - Each instance manages its own:
270
+ * - connection
271
+ * - state
272
+ * - subscribers
273
+ *
274
+ * ---
275
+ *
276
+ * ### 4. React integration (Proxy-based)
277
+ *
278
+ * - The returned hook exposes the full state as a Proxy
279
+ * - Components automatically subscribe to accessed properties
280
+ * - No selector or memoization is required
281
+ *
282
+ * ---
283
+ *
284
+ * ## Execution model
285
+ *
286
+ * - Streams are **lazy**:
287
+ * - No connection until there is a subscriber
288
+ * - Streams are **shared**:
289
+ * - Multiple subscribers reuse the same connection
290
+ * - Streams are **stateful**:
291
+ * - Data persists across reconnects (unless reset or GC)
292
+ *
293
+ * ---
294
+ *
295
+ * @example
296
+ * const chatStream = createStream(
297
+ * (roomId, emit) => {
298
+ * const ws = new WebSocket(`/chat/${roomId}`);
299
+ *
300
+ * ws.onopen = () => emit.connected();
301
+ * ws.onmessage = (e) => {
302
+ * const msg = JSON.parse(e.data);
303
+ * emit.data((prev) => [...(prev ?? []), msg]);
304
+ * };
305
+ * ws.onerror = (err) => emit.error(err);
306
+ *
307
+ * return ws;
308
+ * },
309
+ * (ws) => ws.close()
310
+ * );
311
+ *
312
+ * function Chat({ roomId }) {
313
+ * const useChat = chatStream(roomId);
314
+ * const state = useChat();
315
+ *
316
+ * return <div>{state.data?.length}</div>;
317
+ * }
318
+ */
82
319
  export declare const experimental_createStream: <TConnection, TData, TVariable extends StoreKey, TError = Error>(connect: (variable: TVariable, emit: {
83
320
  connected: () => void;
84
321
  data: (reducer: (data: TData | undefined) => TData) => void;
@@ -91,14 +328,58 @@ export declare const experimental_createStream: <TConnection, TData, TVariable e
91
328
  subscribe: (subscriber: import("../vanilla.ts").Subscriber<StreamState<TData, TError>>) => () => void;
92
329
  getSubscriberCount: () => number;
93
330
  variableHash: string;
331
+ /**
332
+ * Connection controls for the stream.
333
+ *
334
+ * @remarks
335
+ * Provides imperative control over the underlying connection.
336
+ */
94
337
  connection: {
338
+ /**
339
+ * Returns the current connection instance.
340
+ *
341
+ * @returns The active connection or `undefined` if not connected
342
+ */
95
343
  get: () => Readonly<TConnection> | undefined;
344
+ /**
345
+ * Forces a reconnection.
346
+ *
347
+ * @remarks
348
+ * - Cancels any scheduled disconnect
349
+ * - Starts a new connection if not already connecting
350
+ */
96
351
  reconnect: () => void;
352
+ /**
353
+ * Immediately disconnects the current connection.
354
+ *
355
+ * @remarks
356
+ * - Ignores disconnect delay rules
357
+ * - Updates connection state to `DISCONNECTED`
358
+ */
97
359
  disconnect: () => void;
98
360
  };
361
+ /**
362
+ * Data controls for the stream.
363
+ */
99
364
  data: {
365
+ /**
366
+ * Resets the data state back to `INITIAL`.
367
+ *
368
+ * @remarks
369
+ * - Does not affect connection state
370
+ * - Useful for clearing stale or invalid data
371
+ */
100
372
  reset: () => void;
101
373
  };
374
+ /**
375
+ * Deletes the stream instance.
376
+ *
377
+ * @returns `true` if deleted, `false` otherwise
378
+ *
379
+ * @remarks
380
+ * - Cannot delete while there are active subscribers
381
+ * - Clears connection, state, and cached instance
382
+ */
102
383
  delete: () => boolean;
103
384
  };
104
385
  export {};
package/react.js CHANGED
@@ -787,6 +787,19 @@ const experimental_createStream = (connect, disconnect, options = {}) => {
787
787
  const connections = /* @__PURE__ */ new WeakMap();
788
788
  const disconnectFns = /* @__PURE__ */ new WeakMap();
789
789
  const disconnectTimeoutIds = /* @__PURE__ */ new WeakMap();
790
+ const clearAllTimeouts = (store) => {
791
+ const gcTimeoutId = gcTimeoutIds.get(store);
792
+ if (gcTimeoutId) {
793
+ clearTimeout(gcTimeoutId);
794
+ gcTimeoutIds.delete(store);
795
+ }
796
+ const disconnectTimeoutIds_ = disconnectTimeoutIds.get(store);
797
+ if (disconnectTimeoutIds_) {
798
+ clearTimeout(disconnectTimeoutIds_["last-unsubscribe"]);
799
+ clearTimeout(disconnectTimeoutIds_["document-hidden"]);
800
+ clearTimeout(disconnectTimeoutIds_.offline);
801
+ }
802
+ };
790
803
  const gcTimeoutIds = /* @__PURE__ */ new WeakMap();
791
804
  const configureStoreEvents = () => ({
792
805
  ...options,
@@ -829,12 +842,7 @@ const experimental_createStream = (connect, disconnect, options = {}) => {
829
842
  if (store.getSubscriberCount() && showLog) {
830
843
  console.log("Stream disconnected while there is subscriber");
831
844
  }
832
- const disconnectTimeoutIds_ = disconnectTimeoutIds.get(store);
833
- if (disconnectTimeoutIds_) {
834
- clearTimeout(disconnectTimeoutIds_["last-unsubscribe"]);
835
- clearTimeout(disconnectTimeoutIds_["document-hidden"]);
836
- clearTimeout(disconnectTimeoutIds_.offline);
837
- }
845
+ clearAllTimeouts(store);
838
846
  (_a = disconnectFns.get(store)) == null ? void 0 : _a();
839
847
  if (store.getState().connectionState !== "INITIAL") {
840
848
  store.setState({
@@ -847,8 +855,7 @@ const experimental_createStream = (connect, disconnect, options = {}) => {
847
855
  gcTimeoutIds.set(
848
856
  store,
849
857
  setTimeout(() => {
850
- if (store.getSubscriberCount()) store.data.reset();
851
- else store.delete();
858
+ if (store.getSubscriberCount() === 0) store.delete();
852
859
  }, gcTime)
853
860
  );
854
861
  };
@@ -868,13 +875,19 @@ const experimental_createStream = (connect, disconnect, options = {}) => {
868
875
  store.connection = {};
869
876
  store.connection.get = () => connections.get(store);
870
877
  store.connection.reconnect = () => {
871
- var _a;
878
+ clearAllTimeouts(store);
872
879
  const { connectionState } = store.getState();
873
880
  if (connectionState === "CONNECTING") return;
874
- (_a = disconnectFns.get(store)) == null ? void 0 : _a();
881
+ const prevDisconnect = disconnectFns.get(store);
882
+ if (prevDisconnect) {
883
+ prevDisconnect();
884
+ disconnectFns.delete(store);
885
+ }
875
886
  store.setState({
876
887
  connectionState: "CONNECTING",
877
- connectingAt: Date.now()
888
+ connectingAt: Date.now(),
889
+ connectedAt: void 0,
890
+ disconnectedAt: void 0
878
891
  });
879
892
  const connection = connect(variable, {
880
893
  connected: () => {
@@ -886,10 +899,10 @@ const experimental_createStream = (connect, disconnect, options = {}) => {
886
899
  },
887
900
  data: (reducer) => {
888
901
  store.setState((prev) => {
889
- var _a2;
902
+ var _a;
890
903
  return {
891
904
  connectionState: "CONNECTED",
892
- connectedAt: (_a2 = prev.connectedAt) != null ? _a2 : Date.now(),
905
+ connectedAt: (_a = prev.connectedAt) != null ? _a : Date.now(),
893
906
  state: "SUCCESS",
894
907
  isSuccess: true,
895
908
  isError: false,
@@ -932,6 +945,7 @@ const experimental_createStream = (connect, disconnect, options = {}) => {
932
945
  );
933
946
  return false;
934
947
  }
948
+ clearAllTimeouts(store);
935
949
  store.setState(initialState);
936
950
  return stores.delete(variableHash);
937
951
  };
@@ -948,16 +962,13 @@ const experimental_createStream = (connect, disconnect, options = {}) => {
948
962
  });
949
963
  };
950
964
  const triggerReconnect = (store, trigger) => {
951
- clearTimeout(gcTimeoutIds.get(store));
952
- const disconnectTimeoutIds_ = disconnectTimeoutIds.get(store);
953
- if (disconnectTimeoutIds_) {
954
- clearTimeout(disconnectTimeoutIds_["last-unsubscribe"]);
955
- clearTimeout(disconnectTimeoutIds_["document-hidden"]);
956
- clearTimeout(disconnectTimeoutIds_.offline);
957
- }
965
+ clearAllTimeouts(store);
958
966
  const { connectionState } = store.getState();
959
967
  if (connectionState === "INITIAL" || connectionState === "DISCONNECTED") {
960
- return store.connection.reconnect();
968
+ queueMicrotask(() => {
969
+ store.connection.reconnect();
970
+ });
971
+ return;
961
972
  }
962
973
  const shouldReconnect = reconnectOn(trigger, store.getState());
963
974
  if (shouldReconnect) store.connection.reconnect();