@terreno/rtk 0.13.0 → 0.14.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.
Files changed (147) hide show
  1. package/dist/authSlice.test.js +2 -10
  2. package/dist/authSlice.test.js.map +1 -1
  3. package/dist/authSliceNative.test.js +11 -3
  4. package/dist/authSliceNative.test.js.map +1 -1
  5. package/dist/betterAuthSlice.test.js +1 -25
  6. package/dist/betterAuthSlice.test.js.map +1 -1
  7. package/dist/buildNumber.test.js +19 -0
  8. package/dist/buildNumber.test.js.map +1 -1
  9. package/dist/constants.d.ts +3 -0
  10. package/dist/constants.d.ts.map +1 -1
  11. package/dist/constants.js +11 -3
  12. package/dist/constants.js.map +1 -1
  13. package/dist/constants.test.js +44 -2
  14. package/dist/constants.test.js.map +1 -1
  15. package/dist/emptyApi.d.ts +2 -0
  16. package/dist/emptyApi.d.ts.map +1 -1
  17. package/dist/emptyApi.js +5 -0
  18. package/dist/emptyApi.js.map +1 -1
  19. package/dist/index.d.ts +8 -0
  20. package/dist/index.d.ts.map +1 -1
  21. package/dist/index.js +8 -0
  22. package/dist/index.js.map +1 -1
  23. package/dist/isolated/constants.isolated.js +7 -1
  24. package/dist/isolated/constants.isolated.js.map +1 -1
  25. package/dist/isolated/offlineMiddleware.isolated.d.ts +2 -0
  26. package/dist/isolated/offlineMiddleware.isolated.d.ts.map +1 -0
  27. package/dist/isolated/offlineMiddleware.isolated.js +516 -0
  28. package/dist/isolated/offlineMiddleware.isolated.js.map +1 -0
  29. package/dist/isolated/offlineMiddleware.native.isolated.d.ts +2 -0
  30. package/dist/isolated/offlineMiddleware.native.isolated.d.ts.map +1 -0
  31. package/dist/isolated/offlineMiddleware.native.isolated.js +70 -0
  32. package/dist/isolated/offlineMiddleware.native.isolated.js.map +1 -0
  33. package/dist/isolated/useUpgradeCheck.mobile.isolated.d.ts +2 -0
  34. package/dist/isolated/useUpgradeCheck.mobile.isolated.d.ts.map +1 -0
  35. package/dist/{useUpgradeCheck.mobile.test.js → isolated/useUpgradeCheck.mobile.isolated.js} +13 -5
  36. package/dist/isolated/useUpgradeCheck.mobile.isolated.js.map +1 -0
  37. package/dist/isolated/useUpgradeCheck.web.isolated.d.ts +2 -0
  38. package/dist/isolated/useUpgradeCheck.web.isolated.d.ts.map +1 -0
  39. package/dist/{useUpgradeCheck.test.js → isolated/useUpgradeCheck.web.isolated.js} +37 -7
  40. package/dist/isolated/useUpgradeCheck.web.isolated.js.map +1 -0
  41. package/dist/mongooseSlice.test.js +1 -1
  42. package/dist/mongooseSlice.test.js.map +1 -1
  43. package/dist/offlineGate.d.ts +8 -0
  44. package/dist/offlineGate.d.ts.map +1 -0
  45. package/dist/offlineGate.js +20 -0
  46. package/dist/offlineGate.js.map +1 -0
  47. package/dist/offlineGate.test.d.ts +2 -0
  48. package/dist/offlineGate.test.d.ts.map +1 -0
  49. package/dist/offlineGate.test.js +46 -0
  50. package/dist/offlineGate.test.js.map +1 -0
  51. package/dist/offlineMiddleware.d.ts +46 -0
  52. package/dist/offlineMiddleware.d.ts.map +1 -0
  53. package/dist/offlineMiddleware.js +591 -0
  54. package/dist/offlineMiddleware.js.map +1 -0
  55. package/dist/offlineMiddleware.test.d.ts +2 -0
  56. package/dist/offlineMiddleware.test.d.ts.map +1 -0
  57. package/dist/offlineMiddleware.test.js +145 -0
  58. package/dist/offlineMiddleware.test.js.map +1 -0
  59. package/dist/offlineSlice.d.ts +232 -0
  60. package/dist/offlineSlice.d.ts.map +1 -0
  61. package/dist/offlineSlice.js +65 -0
  62. package/dist/offlineSlice.js.map +1 -0
  63. package/dist/offlineSlice.test.d.ts +2 -0
  64. package/dist/offlineSlice.test.d.ts.map +1 -0
  65. package/dist/offlineSlice.test.js +153 -0
  66. package/dist/offlineSlice.test.js.map +1 -0
  67. package/dist/realtime.d.ts +123 -0
  68. package/dist/realtime.d.ts.map +1 -0
  69. package/dist/realtime.js +299 -0
  70. package/dist/realtime.js.map +1 -0
  71. package/dist/realtime.test.d.ts +2 -0
  72. package/dist/realtime.test.d.ts.map +1 -0
  73. package/dist/realtime.test.js +339 -0
  74. package/dist/realtime.test.js.map +1 -0
  75. package/dist/socket.d.ts.map +1 -1
  76. package/dist/socket.js +29 -28
  77. package/dist/socket.js.map +1 -1
  78. package/dist/sync.d.ts +35 -0
  79. package/dist/sync.d.ts.map +1 -0
  80. package/dist/sync.js +205 -0
  81. package/dist/sync.js.map +1 -0
  82. package/dist/tagGenerator.d.ts +20 -1
  83. package/dist/tagGenerator.d.ts.map +1 -1
  84. package/dist/tagGenerator.js +1 -2
  85. package/dist/tagGenerator.js.map +1 -1
  86. package/dist/useOfflineStatus.d.ts +33 -0
  87. package/dist/useOfflineStatus.d.ts.map +1 -0
  88. package/dist/useOfflineStatus.js +43 -0
  89. package/dist/useOfflineStatus.js.map +1 -0
  90. package/dist/useOfflineStatus.test.d.ts +2 -0
  91. package/dist/useOfflineStatus.test.d.ts.map +1 -0
  92. package/dist/useOfflineStatus.test.js +119 -0
  93. package/dist/useOfflineStatus.test.js.map +1 -0
  94. package/dist/useRealtimeDebug.d.ts +6 -0
  95. package/dist/useRealtimeDebug.d.ts.map +1 -0
  96. package/dist/useRealtimeDebug.js +41 -0
  97. package/dist/useRealtimeDebug.js.map +1 -0
  98. package/dist/useServerStatus.d.ts +29 -0
  99. package/dist/useServerStatus.d.ts.map +1 -0
  100. package/dist/useServerStatus.js +89 -0
  101. package/dist/useServerStatus.js.map +1 -0
  102. package/dist/useUpgradeCheck.d.ts +7 -3
  103. package/dist/useUpgradeCheck.d.ts.map +1 -1
  104. package/dist/useUpgradeCheck.js +27 -13
  105. package/dist/useUpgradeCheck.js.map +1 -1
  106. package/package.json +2 -2
  107. package/src/authSlice.test.ts +2 -11
  108. package/src/authSliceNative.test.ts +11 -4
  109. package/src/betterAuthSlice.test.ts +1 -25
  110. package/src/buildNumber.test.ts +19 -0
  111. package/src/constants.test.ts +54 -1
  112. package/src/constants.ts +14 -3
  113. package/src/emptyApi.ts +8 -0
  114. package/src/index.ts +8 -0
  115. package/src/isolated/constants.isolated.ts +7 -3
  116. package/src/isolated/offlineMiddleware.isolated.ts +696 -0
  117. package/src/isolated/offlineMiddleware.native.isolated.ts +80 -0
  118. package/src/{useUpgradeCheck.mobile.test.ts → isolated/useUpgradeCheck.mobile.isolated.ts} +13 -4
  119. package/src/{useUpgradeCheck.test.ts → isolated/useUpgradeCheck.web.isolated.ts} +48 -6
  120. package/src/mongooseSlice.test.ts +1 -1
  121. package/src/offlineGate.test.ts +55 -0
  122. package/src/offlineGate.ts +25 -0
  123. package/src/offlineMiddleware.test.ts +187 -0
  124. package/src/offlineMiddleware.ts +718 -0
  125. package/src/offlineSlice.test.ts +204 -0
  126. package/src/offlineSlice.ts +130 -0
  127. package/src/realtime.test.ts +404 -0
  128. package/src/realtime.ts +408 -0
  129. package/src/socket.ts +33 -32
  130. package/src/sync.ts +310 -0
  131. package/src/tagGenerator.ts +29 -11
  132. package/src/useOfflineStatus.test.ts +157 -0
  133. package/src/useOfflineStatus.ts +82 -0
  134. package/src/useRealtimeDebug.ts +51 -0
  135. package/src/useServerStatus.ts +122 -0
  136. package/src/useUpgradeCheck.ts +35 -13
  137. package/dist/isolated/useUpgradeCheck.isolated.d.ts +0 -2
  138. package/dist/isolated/useUpgradeCheck.isolated.d.ts.map +0 -1
  139. package/dist/isolated/useUpgradeCheck.isolated.js +0 -153
  140. package/dist/isolated/useUpgradeCheck.isolated.js.map +0 -1
  141. package/dist/useUpgradeCheck.mobile.test.d.ts +0 -2
  142. package/dist/useUpgradeCheck.mobile.test.d.ts.map +0 -1
  143. package/dist/useUpgradeCheck.mobile.test.js.map +0 -1
  144. package/dist/useUpgradeCheck.test.d.ts +0 -2
  145. package/dist/useUpgradeCheck.test.d.ts.map +0 -1
  146. package/dist/useUpgradeCheck.test.js.map +0 -1
  147. package/src/isolated/useUpgradeCheck.isolated.ts +0 -201
@@ -0,0 +1,408 @@
1
+ import {DateTime} from "luxon";
2
+ import type {Socket} from "socket.io-client";
3
+
4
+ import {isWebsocketsDebugEnabled, logSocket} from "./constants";
5
+
6
+ interface DocumentData {
7
+ _id?: string;
8
+ id?: string;
9
+ updated?: string;
10
+ [key: string]: unknown;
11
+ }
12
+
13
+ interface ListCacheDraft {
14
+ data?: DocumentData[];
15
+ total?: number;
16
+ [key: string]: unknown;
17
+ }
18
+
19
+ interface CacheLifecycleApi<TDraft> {
20
+ updateCachedData: (updateRecipe: (draft: TDraft) => void) => void;
21
+ cacheDataLoaded: Promise<unknown>;
22
+ cacheEntryRemoved: Promise<void>;
23
+ }
24
+
25
+ /**
26
+ * A real-time sync event received from the server via WebSocket.
27
+ * Must be kept in sync with the backend RealtimeEvent in @terreno/api.
28
+ */
29
+ export interface RealtimeEvent {
30
+ /** Mongoose model name (e.g. "Todo") */
31
+ model: string;
32
+ /** Route path used as tag type (e.g. "todos") */
33
+ collection: string;
34
+ /** The CRUD method that triggered this event */
35
+ method: "create" | "update" | "delete";
36
+ /** Document ID */
37
+ id: string;
38
+ /** Serialized document data (omitted for hard deletes) */
39
+ data?: DocumentData;
40
+ /** Fields that were updated (for update events) */
41
+ updatedFields?: string[];
42
+ /** Epoch milliseconds when the event was generated */
43
+ timestamp: number;
44
+ }
45
+
46
+ // ---------------------------------------------------------------------------
47
+ // Socket management
48
+ // ---------------------------------------------------------------------------
49
+
50
+ let _socket: Socket | null = null;
51
+ const _socketWaiters: ((socket: Socket) => void)[] = [];
52
+
53
+ /**
54
+ * Provide the Socket.io client instance used by `realtimeDocument` and `realtimeList`.
55
+ *
56
+ * Call this once after your socket connects (e.g. inside `useSocketConnection`'s `onConnect`
57
+ * callback, or in a `useEffect` that watches the socket ref).
58
+ *
59
+ * @example
60
+ * ```typescript
61
+ * const { socket } = useSocketConnection({ ... });
62
+ *
63
+ * useEffect(() => {
64
+ * setRealtimeSocket(socket);
65
+ * return () => setRealtimeSocket(null);
66
+ * }, [socket]);
67
+ * ```
68
+ */
69
+ export const setRealtimeSocket = (socket: Socket | null): void => {
70
+ _socket = socket;
71
+ if (socket) {
72
+ while (_socketWaiters.length > 0) {
73
+ const waiter = _socketWaiters.shift();
74
+ waiter?.(socket);
75
+ }
76
+ }
77
+ };
78
+
79
+ /** Get the current socket instance (may be null). */
80
+ export const getRealtimeSocket = (): Socket | null => _socket;
81
+
82
+ /**
83
+ * Returns a promise that resolves as soon as a socket is available.
84
+ * Accepts an optional abort promise — if it resolves first, returns null
85
+ * so the caller can bail out (e.g. when the cache entry is removed).
86
+ */
87
+ const waitForSocket = (abort?: Promise<void>): Promise<Socket | null> => {
88
+ if (_socket) {
89
+ return Promise.resolve(_socket);
90
+ }
91
+ const socketPromise = new Promise<Socket>((resolve) => {
92
+ _socketWaiters.push(resolve);
93
+ });
94
+ if (!abort) {
95
+ return socketPromise;
96
+ }
97
+ return Promise.race([socketPromise, abort.then(() => null)]);
98
+ };
99
+
100
+ // ---------------------------------------------------------------------------
101
+ // Helpers
102
+ // ---------------------------------------------------------------------------
103
+
104
+ /** Keys that represent pagination/sorting, not document field filters. */
105
+ const PAGINATION_KEYS = new Set(["limit", "page", "sort", "skip", "offset", "cursor"]);
106
+
107
+ /**
108
+ * Strip pagination params from a query argument to get just the filter.
109
+ * Returns undefined if no filter fields remain.
110
+ */
111
+ const extractQueryFilter = (
112
+ arg: Record<string, unknown> | null | undefined
113
+ ): Record<string, unknown> | undefined => {
114
+ if (!arg || typeof arg !== "object") {
115
+ return undefined;
116
+ }
117
+ const filter: Record<string, unknown> = {};
118
+ for (const [key, value] of Object.entries(arg)) {
119
+ if (!PAGINATION_KEYS.has(key)) {
120
+ filter[key] = value;
121
+ }
122
+ }
123
+ return Object.keys(filter).length > 0 ? filter : undefined;
124
+ };
125
+
126
+ /** Normalize websocket document payloads to match REST API shape (`id` from `_id`). */
127
+ const normalizeRealtimeData = (data: DocumentData): DocumentData => {
128
+ if (data._id != null && data.id == null) {
129
+ return {...data, id: data._id};
130
+ }
131
+ return data;
132
+ };
133
+
134
+ /** Deterministic hash for a query object — used as the room ID. */
135
+ const hashQuery = (collection: string, query: Record<string, unknown>): string => {
136
+ const sortedKeys = Object.keys(query).sort();
137
+ const normalized: Record<string, unknown> = {};
138
+ for (const key of sortedKeys) {
139
+ normalized[key] = query[key];
140
+ }
141
+ return `${collection}:${JSON.stringify(normalized)}`;
142
+ };
143
+
144
+ // ---------------------------------------------------------------------------
145
+ // onCacheEntryAdded factories
146
+ // ---------------------------------------------------------------------------
147
+
148
+ interface RealtimeDocumentOptions {
149
+ /**
150
+ * Extract the document ID from the RTK Query argument.
151
+ * Defaults to `arg` if string, or `arg.id ?? arg._id`.
152
+ */
153
+ getId?: (arg: unknown) => string | undefined;
154
+ }
155
+
156
+ /**
157
+ * Factory that returns an `onCacheEntryAdded` callback for real-time
158
+ * updates on a **single document**.
159
+ *
160
+ * Subscribes to `document:{collection}:{id}` room on the server. When a sync
161
+ * event arrives for this document, patches the RTK Query cache in-place.
162
+ *
163
+ * @param collection - The collection tag (e.g. "todos")
164
+ * @param options - Optional configuration
165
+ *
166
+ * @example
167
+ * ```typescript
168
+ * const api = generatedApi.enhanceEndpoints({
169
+ * endpoints: {
170
+ * getTodosIdRead: {
171
+ * onCacheEntryAdded: realtimeDocument("todos"),
172
+ * },
173
+ * },
174
+ * });
175
+ * ```
176
+ */
177
+ export const realtimeDocument = (collection: string, options?: RealtimeDocumentOptions) => {
178
+ const getId =
179
+ options?.getId ??
180
+ ((arg: unknown): string | undefined => {
181
+ if (typeof arg === "string") {
182
+ return arg;
183
+ }
184
+ const obj = arg as Record<string, unknown> | null | undefined;
185
+ return (obj?.id as string | undefined) ?? (obj?._id as string | undefined);
186
+ });
187
+
188
+ return async (arg: unknown, api: CacheLifecycleApi<DocumentData>): Promise<void> => {
189
+ const {updateCachedData, cacheDataLoaded, cacheEntryRemoved} = api;
190
+
191
+ const id = getId(arg);
192
+ if (!id) {
193
+ return;
194
+ }
195
+
196
+ try {
197
+ await cacheDataLoaded;
198
+ } catch {
199
+ return;
200
+ }
201
+
202
+ const socket = await waitForSocket(cacheEntryRemoved);
203
+ if (!socket) {
204
+ return;
205
+ }
206
+
207
+ socket.emit("subscribe:document", {collection, id});
208
+
209
+ const handleSync = (event: RealtimeEvent): void => {
210
+ if (isWebsocketsDebugEnabled()) {
211
+ logSocket(true, `realtimeDocument(${collection}/${id}) sync: ${JSON.stringify(event)}`);
212
+ }
213
+
214
+ if (event.collection !== collection || event.id !== id) {
215
+ return;
216
+ }
217
+
218
+ if (event.method === "update" && event.data) {
219
+ const data = normalizeRealtimeData(event.data);
220
+ updateCachedData((draft: DocumentData) => {
221
+ Object.assign(draft, data);
222
+ });
223
+ }
224
+
225
+ // For deletes, the cache entry will be invalidated by tag invalidation
226
+ // or the consuming component can handle the deleted state.
227
+ };
228
+
229
+ socket.on("sync", handleSync);
230
+
231
+ await cacheEntryRemoved;
232
+ socket.off("sync", handleSync);
233
+ socket.emit("unsubscribe:document", {collection, id});
234
+ };
235
+ };
236
+
237
+ interface RealtimeListOptions {
238
+ /**
239
+ * Extract the query filter from the RTK Query argument.
240
+ * Defaults to stripping pagination keys (limit, page, sort, skip, offset, cursor).
241
+ * Return undefined to subscribe to the model room instead of a query room.
242
+ */
243
+ getQuery?: (arg: unknown) => Record<string, unknown> | undefined;
244
+ }
245
+
246
+ interface QuerySubscribedPayload {
247
+ clientQueryId?: string;
248
+ collection: string;
249
+ queryId: string;
250
+ }
251
+
252
+ /**
253
+ * Factory that returns an `onCacheEntryAdded` callback for real-time
254
+ * updates on a **list of documents**, optionally filtered by query.
255
+ *
256
+ * If the query argument contains filter fields (after stripping pagination params),
257
+ * subscribes to `query:{queryId}` so the server only sends matching events.
258
+ * Otherwise subscribes to `model:{collection}` for all events.
259
+ *
260
+ * Handles:
261
+ * - **create** → prepends new document to the list
262
+ * - **update** → patches existing document in-place, or adds it if newly matching
263
+ * - **delete** → removes document from the list
264
+ *
265
+ * @param collection - The collection tag (e.g. "todos")
266
+ * @param options - Optional configuration
267
+ *
268
+ * @example
269
+ * ```typescript
270
+ * const api = generatedApi.enhanceEndpoints({
271
+ * endpoints: {
272
+ * getTodosList: {
273
+ * onCacheEntryAdded: realtimeList("todos"),
274
+ * },
275
+ * },
276
+ * });
277
+ * ```
278
+ */
279
+ export const realtimeList = (collection: string, options?: RealtimeListOptions) => {
280
+ const getQuery =
281
+ options?.getQuery ??
282
+ ((arg: unknown) => extractQueryFilter(arg as Record<string, unknown> | null | undefined));
283
+
284
+ return async (arg: unknown, api: CacheLifecycleApi<ListCacheDraft>): Promise<void> => {
285
+ const {updateCachedData, cacheDataLoaded, cacheEntryRemoved} = api;
286
+
287
+ try {
288
+ await cacheDataLoaded;
289
+ } catch {
290
+ return;
291
+ }
292
+
293
+ const socket = await waitForSocket(cacheEntryRemoved);
294
+ if (!socket) {
295
+ return;
296
+ }
297
+
298
+ const query = getQuery(arg);
299
+ let queryId: string | undefined;
300
+ let canonicalQueryId: string | undefined;
301
+ let handleQuerySubscribed: ((payload: QuerySubscribedPayload) => void) | undefined;
302
+
303
+ if (query) {
304
+ queryId = hashQuery(collection, query);
305
+ handleQuerySubscribed = (payload: QuerySubscribedPayload): void => {
306
+ if (payload.collection !== collection) {
307
+ return;
308
+ }
309
+ if (payload.clientQueryId && payload.clientQueryId !== queryId) {
310
+ return;
311
+ }
312
+ canonicalQueryId = payload.queryId;
313
+ };
314
+ socket.on("query:subscribed", handleQuerySubscribed);
315
+ socket.emit("subscribe:query", {collection, query, queryId});
316
+ } else {
317
+ socket.emit("subscribe:model", collection);
318
+ }
319
+
320
+ const handleSync = (event: RealtimeEvent): void => {
321
+ if (isWebsocketsDebugEnabled()) {
322
+ logSocket(true, `realtimeList(${collection}) sync: ${JSON.stringify(event)}`);
323
+ }
324
+
325
+ if (event.collection !== collection) {
326
+ return;
327
+ }
328
+
329
+ switch (event.method) {
330
+ case "create": {
331
+ if (event.data) {
332
+ const data = normalizeRealtimeData(event.data);
333
+ updateCachedData((draft: ListCacheDraft) => {
334
+ if (draft?.data && Array.isArray(draft.data)) {
335
+ draft.data.unshift(data);
336
+ if (typeof draft.total === "number") {
337
+ draft.total += 1;
338
+ }
339
+ }
340
+ });
341
+ }
342
+ break;
343
+ }
344
+
345
+ case "update": {
346
+ if (event.data) {
347
+ const data = normalizeRealtimeData(event.data);
348
+ updateCachedData((draft: ListCacheDraft) => {
349
+ if (draft?.data && Array.isArray(draft.data)) {
350
+ const index = draft.data.findIndex(
351
+ (item: DocumentData) => item._id === event.id || item.id === event.id
352
+ );
353
+ if (index !== -1) {
354
+ // Stale event check
355
+ const cachedUpdated = draft.data[index].updated;
356
+ if (
357
+ cachedUpdated &&
358
+ data.updated &&
359
+ DateTime.fromISO(cachedUpdated) > DateTime.fromISO(data.updated)
360
+ ) {
361
+ return;
362
+ }
363
+ Object.assign(draft.data[index], data);
364
+ } else {
365
+ // Document newly matches query — add it to the list
366
+ draft.data.unshift(data);
367
+ if (typeof draft.total === "number") {
368
+ draft.total += 1;
369
+ }
370
+ }
371
+ }
372
+ });
373
+ }
374
+ break;
375
+ }
376
+
377
+ case "delete": {
378
+ updateCachedData((draft: ListCacheDraft) => {
379
+ if (draft?.data && Array.isArray(draft.data)) {
380
+ const before = draft.data.length;
381
+ draft.data = draft.data.filter(
382
+ (item: DocumentData) => item._id !== event.id && item.id !== event.id
383
+ );
384
+ if (draft.data.length < before && typeof draft.total === "number") {
385
+ draft.total = Math.max(0, draft.total - 1);
386
+ }
387
+ }
388
+ });
389
+ break;
390
+ }
391
+ }
392
+ };
393
+
394
+ socket.on("sync", handleSync);
395
+
396
+ await cacheEntryRemoved;
397
+ socket.off("sync", handleSync);
398
+ if (handleQuerySubscribed) {
399
+ socket.off("query:subscribed", handleQuerySubscribed);
400
+ }
401
+
402
+ if (queryId) {
403
+ socket.emit("unsubscribe:query", {queryId: canonicalQueryId ?? queryId});
404
+ } else {
405
+ socket.emit("unsubscribe:model", collection);
406
+ }
407
+ };
408
+ };
package/src/socket.ts CHANGED
@@ -37,6 +37,7 @@ export const useSocketConnection = ({
37
37
  isSocketConnected: SocketConnection;
38
38
  } => {
39
39
  const toast = useToast();
40
+ const socketRef = useRef<Socket | null>(null);
40
41
  const [socket, setSocket] = useState<Socket | null>(null);
41
42
  const isConnectedRef = useRef<SocketConnection>(undefined);
42
43
  const [isSocketConnected, setIsSocketConnected] = useState<SocketConnection>({
@@ -59,13 +60,17 @@ export const useSocketConnection = ({
59
60
  reconnectionAttempts: 5,
60
61
  reconnectionDelay: 1000,
61
62
  reconnectionDelayMax: 5000,
62
- transports: ["websocket"],
63
+ // Start with polling so dev server restarts don't fail the initial websocket-only
64
+ // handshake; Socket.io upgrades to websocket once the connection is established.
65
+ transports: ["polling", "websocket"],
63
66
  });
64
67
 
68
+ socketRef.current = socketIo;
65
69
  setSocket(socketIo);
66
70
 
67
71
  return (): void => {
68
72
  socketIo.disconnect();
73
+ socketRef.current = null;
69
74
  };
70
75
  }, [baseUrl]);
71
76
 
@@ -97,17 +102,18 @@ export const useSocketConnection = ({
97
102
  logAuth("[SocketConnection] Token received from getAuthToken.");
98
103
  }
99
104
 
100
- if (socket) {
105
+ if (socketRef.current) {
106
+ const socketInstance = socketRef.current;
101
107
  // Enhanced logging for Option 1 (token status)
102
108
  logAuth(
103
- `[SocketConnection] Socket connecting ${token ? "with" : "without"} token. Current socket state: ${socket.connected ? "connected" : "disconnected"}`
109
+ `[SocketConnection] Socket connecting ${token ? "with" : "without"} token. Current socket state: ${socketInstance.connected ? "connected" : "disconnected"}`
104
110
  );
105
- socket.auth = {token: `Bearer ${token}`};
106
- socket.connect();
111
+ socketInstance.auth = {token: `Bearer ${token}`};
112
+ socketInstance.connect();
107
113
  } else {
108
- console.warn("[SocketConnection] connectSocket called but socket instance is null.");
114
+ logAuth("[SocketConnection] connectSocket skipped socket not initialized yet.");
109
115
  }
110
- }, [socket, getAuthToken]);
116
+ }, [getAuthToken]);
111
117
 
112
118
  // Extracted logic for checking token expiration, refreshing token, and handling related UI
113
119
  const checkAndRefreshTokenLogic = useCallback(
@@ -130,11 +136,12 @@ export const useSocketConnection = ({
130
136
  );
131
137
  await refreshAuthToken();
132
138
  // Attempt to reconnect after token refresh
133
- if (shouldConnect && socket && !socket.connected) {
139
+ const socketInstance = socketRef.current;
140
+ if (shouldConnect && socketInstance && !socketInstance.connected) {
134
141
  logAuth(
135
142
  `[SocketConnection] Attempting to reconnect socket after token refresh due to ${context}.`
136
143
  );
137
- socket.connect();
144
+ socketInstance.connect();
138
145
  }
139
146
  }
140
147
  } catch (error) {
@@ -171,7 +178,7 @@ export const useSocketConnection = ({
171
178
  }
172
179
  }
173
180
  },
174
- [shouldConnect, socket, captureEvent, hideDisconnectedToast, toast, hideTokenErrorToast]
181
+ [shouldConnect, captureEvent, hideDisconnectedToast, toast, hideTokenErrorToast]
175
182
  );
176
183
 
177
184
  // Use Redux state for token refresh signal
@@ -200,8 +207,12 @@ export const useSocketConnection = ({
200
207
  previousTokenRefreshTimestampRef.current = lastTokenRefreshTimestamp;
201
208
  }, [lastTokenRefreshTimestamp, socket, shouldConnect, connectSocket, hideTokenErrorToast]);
202
209
 
203
- // Connect/disconnect socket based on shouldConnect flag
210
+ // Connect/disconnect socket based on shouldConnect flag (waits until socket is initialized)
204
211
  useEffect(() => {
212
+ if (!socket) {
213
+ return;
214
+ }
215
+
205
216
  if (shouldConnect) {
206
217
  if (!isSocketConnected.isConnected) {
207
218
  logAuth(
@@ -214,35 +225,17 @@ export const useSocketConnection = ({
214
225
  );
215
226
  }
216
227
  } else {
228
+ logAuth(`[SocketConnection] Disconnecting socket because shouldConnect is false.`);
229
+ socket?.disconnect();
217
230
  if (isSocketConnected.isConnected) {
218
- logAuth(
219
- `[SocketConnection] Attempting to disconnect socket because shouldConnect is false and socket is connected.`
220
- );
221
- socket?.disconnect();
222
231
  setIsSocketConnected({
223
232
  isConnected: false,
224
- lastDisconnectedAt: null, // null because this was intentional
233
+ lastDisconnectedAt: null,
225
234
  });
226
- } else {
227
- logAuth(
228
- `[SocketConnection] Socket is already disconnected and shouldConnect is false. No action needed.`
229
- );
230
235
  }
231
236
  }
232
237
  }, [connectSocket, shouldConnect, isSocketConnected, socket]);
233
238
 
234
- // Attempt to reconnect if token was refreshed and we are disconnected
235
- useEffect(() => {
236
- if (shouldConnect && !isSocketConnected.isConnected && socket) {
237
- logAuth("[SocketConnection] Token refresh detected, attempting to reconnect socket.");
238
- // We might want to ensure the socket isn't already in a connecting state here
239
- // if socket.io-client provides such a state.
240
- // Forcing a disconnect first can help if it's stuck in a bad state.
241
- socket.disconnect();
242
- void connectSocket();
243
- }
244
- }, [shouldConnect, isSocketConnected.isConnected, socket, connectSocket]);
245
-
246
239
  // Show toast when disconnected
247
240
  useEffect(() => {
248
241
  if (!shouldConnect) {
@@ -350,6 +343,14 @@ export const useSocketConnection = ({
350
343
 
351
344
  const handleConnectError = async (connectionError: Error): Promise<void> => {
352
345
  const tokenInfo = await getFriendlyExpirationInfo();
346
+ const browserOffline = typeof navigator !== "undefined" && !navigator.onLine;
347
+
348
+ if (browserOffline) {
349
+ logAuth(
350
+ `[SocketConnection] Socket connection skipped while offline: ${connectionError.message}`
351
+ );
352
+ return;
353
+ }
353
354
 
354
355
  console.error(
355
356
  "[SocketConnection] Socket connection error:",