@terreno/rtk 0.25.0 → 0.27.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 (90) hide show
  1. package/.ai/guidelines/core.md +2 -0
  2. package/dist/authSlice.d.ts.map +1 -1
  3. package/dist/authSlice.js +3 -1
  4. package/dist/authSlice.js.map +1 -1
  5. package/dist/authSlice.test.js +15 -8
  6. package/dist/authSlice.test.js.map +1 -1
  7. package/dist/authSliceNative.test.js +3 -16
  8. package/dist/authSliceNative.test.js.map +1 -1
  9. package/dist/betterAuthClient.d.ts.map +1 -1
  10. package/dist/betterAuthClient.js +0 -1
  11. package/dist/betterAuthClient.js.map +1 -1
  12. package/dist/betterAuthSlice.test.js +1 -0
  13. package/dist/betterAuthSlice.test.js.map +1 -1
  14. package/dist/constants.d.ts +7 -1
  15. package/dist/constants.d.ts.map +1 -1
  16. package/dist/constants.js.map +1 -1
  17. package/dist/emptyApi.d.ts.map +1 -1
  18. package/dist/emptyApi.js +0 -2
  19. package/dist/emptyApi.js.map +1 -1
  20. package/dist/isolated/constants.isolated.d.ts +2 -0
  21. package/dist/isolated/constants.isolated.d.ts.map +1 -0
  22. package/dist/isolated/constants.isolated.js +81 -0
  23. package/dist/isolated/constants.isolated.js.map +1 -0
  24. package/dist/isolated/constants.test.js +53 -75
  25. package/dist/isolated/constants.test.js.map +1 -1
  26. package/dist/isolated/offlineMiddleware.isolated.js +0 -1
  27. package/dist/isolated/offlineMiddleware.isolated.js.map +1 -1
  28. package/dist/isolated/offlineMiddleware.native.isolated.js +0 -1
  29. package/dist/isolated/offlineMiddleware.native.isolated.js.map +1 -1
  30. package/dist/isolated/useUpgradeCheck.mobile.isolated.js +47 -0
  31. package/dist/isolated/useUpgradeCheck.mobile.isolated.js.map +1 -1
  32. package/dist/mongooseSlice.test.js +0 -1
  33. package/dist/mongooseSlice.test.js.map +1 -1
  34. package/dist/offlineGate.d.ts +1 -1
  35. package/dist/offlineGate.d.ts.map +1 -1
  36. package/dist/offlineGate.js +1 -3
  37. package/dist/offlineGate.js.map +1 -1
  38. package/dist/offlineMiddleware.d.ts.map +1 -1
  39. package/dist/offlineMiddleware.js +26 -0
  40. package/dist/offlineMiddleware.js.map +1 -1
  41. package/dist/offlineMiddleware.test.js +5 -1
  42. package/dist/offlineMiddleware.test.js.map +1 -1
  43. package/dist/realtime.test.js +1 -0
  44. package/dist/realtime.test.js.map +1 -1
  45. package/dist/sessionApi.test.d.ts +2 -0
  46. package/dist/sessionApi.test.d.ts.map +1 -0
  47. package/dist/sessionApi.test.js +84 -0
  48. package/dist/sessionApi.test.js.map +1 -0
  49. package/dist/sync.d.ts.map +1 -1
  50. package/dist/sync.js.map +1 -1
  51. package/dist/sync.test.d.ts +2 -0
  52. package/dist/sync.test.d.ts.map +1 -0
  53. package/dist/sync.test.js +296 -0
  54. package/dist/sync.test.js.map +1 -0
  55. package/dist/testPreload.test.js +6 -0
  56. package/dist/testPreload.test.js.map +1 -1
  57. package/dist/useFeatureFlags.d.ts.map +1 -1
  58. package/dist/useFeatureFlags.js.map +1 -1
  59. package/dist/useTerrenoFeatureFlags.d.ts.map +1 -1
  60. package/dist/useTerrenoFeatureFlags.js.map +1 -1
  61. package/dist/useTerrenoFeatureFlags.test.js +10 -0
  62. package/dist/useTerrenoFeatureFlags.test.js.map +1 -1
  63. package/dist/useUpgradeCheckNative.test.js +21 -0
  64. package/dist/useUpgradeCheckNative.test.js.map +1 -1
  65. package/package.json +2 -2
  66. package/src/authSlice.test.ts +25 -13
  67. package/src/authSlice.ts +3 -1
  68. package/src/authSliceNative.test.ts +4 -18
  69. package/src/betterAuthClient.ts +1 -2
  70. package/src/betterAuthSlice.test.ts +1 -0
  71. package/src/constants.ts +7 -2
  72. package/src/emptyApi.ts +5 -4
  73. package/src/isolated/constants.isolated.ts +118 -0
  74. package/src/isolated/constants.test.ts +81 -78
  75. package/src/isolated/offlineMiddleware.isolated.ts +1 -2
  76. package/src/isolated/offlineMiddleware.native.isolated.ts +1 -2
  77. package/src/isolated/useUpgradeCheck.mobile.isolated.ts +60 -0
  78. package/src/mongooseSlice.test.ts +1 -2
  79. package/src/offlineGate.ts +1 -2
  80. package/src/offlineMiddleware.test.ts +6 -1
  81. package/src/offlineMiddleware.ts +27 -0
  82. package/src/realtime.test.ts +1 -0
  83. package/src/sessionApi.test.ts +115 -0
  84. package/src/sync.test.ts +390 -0
  85. package/src/sync.ts +2 -1
  86. package/src/testPreload.test.ts +7 -0
  87. package/src/useFeatureFlags.ts +1 -0
  88. package/src/useTerrenoFeatureFlags.test.ts +16 -0
  89. package/src/useTerrenoFeatureFlags.ts +2 -0
  90. package/src/useUpgradeCheckNative.test.ts +26 -0
@@ -28,6 +28,7 @@ export interface OfflineMiddlewareConfig {
28
28
  /** RTK Query mutation endpoint names to queue when offline */
29
29
  endpoints: string[];
30
30
  /** The RTK Query API instance */
31
+ // noExplicitAny: Generic API type
31
32
  // biome-ignore lint/suspicious/noExplicitAny: Generic API type
32
33
  api: Api<any, any, any, any>;
33
34
  }
@@ -54,6 +55,7 @@ const inferMutationType = (endpointName: string): "create" | "update" | "delete"
54
55
  * not an auth or application-level FETCH_ERROR from emptyApi.
55
56
  */
56
57
  export const isNetworkFetchError = (
58
+ // noExplicitAny: RTK Query error shapes vary by source
57
59
  // biome-ignore lint/suspicious/noExplicitAny: RTK Query error shapes vary by source
58
60
  source: any
59
61
  ): boolean => {
@@ -141,8 +143,10 @@ const inferGetByIdEndpointName = (endpointName: string): string => {
141
143
  const extractBaseUpdatedAt = (
142
144
  endpointName: string,
143
145
  originalArgs: unknown,
146
+ // noExplicitAny: Generic getState
144
147
  // biome-ignore lint/suspicious/noExplicitAny: Generic getState
145
148
  getState: () => any,
149
+ // noExplicitAny: Generic API type
146
150
  // biome-ignore lint/suspicious/noExplicitAny: Generic API type
147
151
  api: Api<any, any, any, any>
148
152
  ): string | undefined => {
@@ -162,6 +166,7 @@ const extractBaseUpdatedAt = (
162
166
 
163
167
  const getByIdEndpoint = inferGetByIdEndpointName(endpointName);
164
168
  const state = getState();
169
+ // noExplicitAny: RTK Query internal state shape
165
170
  // biome-ignore lint/suspicious/noExplicitAny: RTK Query internal state shape
166
171
  const queries: Record<string, any> = state[api.reducerPath]?.queries ?? {};
167
172
  for (const key of Object.keys(queries)) {
@@ -188,13 +193,16 @@ const extractBaseUpdatedAt = (
188
193
  * args the consumer passed to the query hook, e.g. `{}` vs `undefined`).
189
194
  */
190
195
  const getCachedQueryArgs = (
196
+ // noExplicitAny: Generic API state shape
191
197
  // biome-ignore lint/suspicious/noExplicitAny: Generic API state shape
192
198
  getState: () => any,
199
+ // noExplicitAny: Generic API type
193
200
  // biome-ignore lint/suspicious/noExplicitAny: Generic API type
194
201
  api: Api<any, any, any, any>,
195
202
  listEndpointName: string
196
203
  ): unknown[] => {
197
204
  const state = getState();
205
+ // noExplicitAny: RTK Query internal state shape
198
206
  // biome-ignore lint/suspicious/noExplicitAny: RTK Query internal state shape
199
207
  const queries: Record<string, any> = state[api.reducerPath]?.queries ?? {};
200
208
  const cachedArgs: unknown[] = [];
@@ -213,10 +221,13 @@ const getCachedQueryArgs = (
213
221
  * Apply an optimistic update to the RTK Query cache for a queued mutation.
214
222
  */
215
223
  const applyOptimisticUpdate = (
224
+ // noExplicitAny: Generic API type
216
225
  // biome-ignore lint/suspicious/noExplicitAny: Generic API type
217
226
  api: Api<any, any, any, any>,
227
+ // noExplicitAny: Generic dispatch
218
228
  // biome-ignore lint/suspicious/noExplicitAny: Generic dispatch
219
229
  dispatch: any,
230
+ // noExplicitAny: Generic getState
220
231
  // biome-ignore lint/suspicious/noExplicitAny: Generic getState
221
232
  getState: () => any,
222
233
  mutation: QueuedMutation
@@ -225,10 +236,12 @@ const applyOptimisticUpdate = (
225
236
  const listEndpointName = `get${tagType.charAt(0).toUpperCase() + tagType.slice(1)}`;
226
237
  const cachedArgs = getCachedQueryArgs(getState, api, listEndpointName);
227
238
 
239
+ // noExplicitAny: RTK Query cache shape varies by endpoint
228
240
  // biome-ignore lint/suspicious/noExplicitAny: RTK Query cache shape varies by endpoint
229
241
  const updateAllCacheEntries = (updater: (draft: any) => void): void => {
230
242
  for (const queryArg of cachedArgs) {
231
243
  dispatch(
244
+ // noExplicitAny: RTK Query cache shape varies by endpoint
232
245
  // biome-ignore lint/suspicious/noExplicitAny: RTK Query cache shape varies by endpoint
233
246
  api.util.updateQueryData(listEndpointName as any, queryArg, updater)
234
247
  );
@@ -238,6 +251,7 @@ const applyOptimisticUpdate = (
238
251
  if (mutation.type === "create") {
239
252
  const args = mutation.args as {body?: Record<string, unknown>};
240
253
  const tempItem = buildOptimisticCreateItem(mutation, args?.body);
254
+ // noExplicitAny: RTK Query cache shape varies by endpoint
241
255
  // biome-ignore lint/suspicious/noExplicitAny: RTK Query cache shape varies by endpoint
242
256
  updateAllCacheEntries((draft: any) => {
243
257
  if (draft?.data && Array.isArray(draft.data)) {
@@ -247,6 +261,7 @@ const applyOptimisticUpdate = (
247
261
  } else if (mutation.type === "update") {
248
262
  const args = mutation.args as {id?: string; body?: Record<string, unknown>};
249
263
  if (args?.id && args?.body) {
264
+ // noExplicitAny: RTK Query cache shape varies by endpoint
250
265
  // biome-ignore lint/suspicious/noExplicitAny: RTK Query cache shape varies by endpoint
251
266
  updateAllCacheEntries((draft: any) => {
252
267
  if (draft?.data && Array.isArray(draft.data)) {
@@ -262,6 +277,7 @@ const applyOptimisticUpdate = (
262
277
  } else if (mutation.type === "delete") {
263
278
  const args = mutation.args as {id?: string};
264
279
  if (args?.id) {
280
+ // noExplicitAny: RTK Query cache shape varies by endpoint
265
281
  // biome-ignore lint/suspicious/noExplicitAny: RTK Query cache shape varies by endpoint
266
282
  updateAllCacheEntries((draft: any) => {
267
283
  if (draft?.data && Array.isArray(draft.data)) {
@@ -278,10 +294,13 @@ const applyOptimisticUpdate = (
278
294
  * Remove optimistic temp items from the cache after they've been replayed.
279
295
  */
280
296
  const removeTempItems = (
297
+ // noExplicitAny: Generic API type
281
298
  // biome-ignore lint/suspicious/noExplicitAny: Generic API type
282
299
  api: Api<any, any, any, any>,
300
+ // noExplicitAny: Generic dispatch
283
301
  // biome-ignore lint/suspicious/noExplicitAny: Generic dispatch
284
302
  dispatch: any,
303
+ // noExplicitAny: Generic getState
285
304
  // biome-ignore lint/suspicious/noExplicitAny: Generic getState
286
305
  getState: () => any,
287
306
  mutations: QueuedMutation[]
@@ -295,6 +314,7 @@ const removeTempItems = (
295
314
 
296
315
  for (const queryArg of cachedArgs) {
297
316
  dispatch(
317
+ // noExplicitAny: RTK Query cache shape varies by endpoint
298
318
  // biome-ignore lint/suspicious/noExplicitAny: RTK Query cache shape varies by endpoint
299
319
  api.util.updateQueryData(listEndpointName as any, queryArg, (draft: any) => {
300
320
  if (draft?.data && Array.isArray(draft.data)) {
@@ -369,6 +389,7 @@ const replayMutation = async (
369
389
  * Web apps should use `useServerStatus` for server-level health checking.
370
390
  */
371
391
  const setupNativeNetworkMonitoring = (
392
+ // noExplicitAny: Generic dispatch
372
393
  // biome-ignore lint/suspicious/noExplicitAny: Generic dispatch
373
394
  dispatch: any
374
395
  ): (() => void) => {
@@ -438,6 +459,7 @@ export const createOfflineMiddleware = (
438
459
  // RTK Query dispatches actions with type "terreno-rtk/executeMutation/rejected"
439
460
  // when a mutation fails. Queue the mutation and apply an optimistic cache update.
440
461
  listenerMiddleware.startListening({
462
+ // noExplicitAny: RTK Query action types are complex
441
463
  // biome-ignore lint/suspicious/noExplicitAny: RTK Query action types are complex
442
464
  effect: async (action: any, listenerApi) => {
443
465
  const state = listenerApi.getState() as {offline: OfflineState};
@@ -464,11 +486,13 @@ export const createOfflineMiddleware = (
464
486
  const listEndpointName = `get${tagType.charAt(0).toUpperCase() + tagType.slice(1)}`;
465
487
  const cachedArgs = getCachedQueryArgs(listenerApi.getState, api, listEndpointName);
466
488
  for (const queryArg of cachedArgs) {
489
+ // noExplicitAny: RTK Query cache shape and endpoint types vary
467
490
  // biome-ignore lint/suspicious/noExplicitAny: RTK Query cache shape and endpoint types vary
468
491
  const endpoint = (api.endpoints as any)[listEndpointName];
469
492
  if (!endpoint?.select) {
470
493
  continue;
471
494
  }
495
+ // noExplicitAny: RTK Query cache shape varies
472
496
  // biome-ignore lint/suspicious/noExplicitAny: RTK Query cache shape varies
473
497
  const cacheEntry = endpoint.select(queryArg)(listenerApi.getState() as any) as any;
474
498
  const items = cacheEntry?.data?.data;
@@ -513,6 +537,7 @@ export const createOfflineMiddleware = (
513
537
  listenerApi.dispatch(setOnlineStatus(false));
514
538
  }
515
539
  },
540
+ // noExplicitAny: RTK Query internal action shape
516
541
  // biome-ignore lint/suspicious/noExplicitAny: RTK Query internal action shape
517
542
  predicate: (action: any) => {
518
543
  if (typeof action?.type !== "string") {
@@ -535,6 +560,7 @@ export const createOfflineMiddleware = (
535
560
  listenerApi.dispatch(setOnlineStatus(false));
536
561
  }
537
562
  },
563
+ // noExplicitAny: RTK Query internal action shape
538
564
  // biome-ignore lint/suspicious/noExplicitAny: RTK Query internal action shape
539
565
  predicate: (action: any) => {
540
566
  if (typeof action?.type !== "string") {
@@ -557,6 +583,7 @@ export const createOfflineMiddleware = (
557
583
  listenerApi.dispatch(setOnlineStatus(true));
558
584
  }
559
585
  },
586
+ // noExplicitAny: RTK Query internal action shape
560
587
  // biome-ignore lint/suspicious/noExplicitAny: RTK Query internal action shape
561
588
  predicate: (action: any) => {
562
589
  if (typeof action?.type !== "string") {
@@ -1,3 +1,4 @@
1
+ // noExplicitAny: realtime RTK tests mock Socket.io and RTK Query runtime shapes
1
2
  // biome-ignore-all lint/suspicious/noExplicitAny: realtime RTK tests mock Socket.io and RTK Query runtime shapes
2
3
  import {afterEach, describe, expect, it} from "bun:test";
3
4
  import type {Socket} from "socket.io-client";
@@ -0,0 +1,115 @@
1
+ import {afterEach, describe, expect, it, mock} from "bun:test";
2
+ import {configureStore} from "@reduxjs/toolkit";
3
+
4
+ import {createSessionApi} from "./sessionApi";
5
+
6
+ type FetchArgs = Parameters<typeof fetch>;
7
+
8
+ interface RecordedFetch {
9
+ urls: string[];
10
+ handler: (input: FetchArgs[0], init: FetchArgs[1]) => Response;
11
+ }
12
+
13
+ const jsonResponse = (body: unknown, status = 200): Response =>
14
+ new Response(JSON.stringify(body), {
15
+ headers: {"Content-Type": "application/json"},
16
+ status,
17
+ });
18
+
19
+ const originalFetch = global.fetch;
20
+ let recorded: RecordedFetch;
21
+
22
+ const setFetch = (handler: RecordedFetch["handler"]): void => {
23
+ recorded = {handler, urls: []};
24
+ global.fetch = mock((input: FetchArgs[0], init: FetchArgs[1]) => {
25
+ const url =
26
+ typeof input === "string" ? input : input instanceof Request ? input.url : String(input);
27
+ recorded.urls.push(url);
28
+ return Promise.resolve(handler(input, init));
29
+ }) as unknown as typeof fetch;
30
+ };
31
+
32
+ const buildStore = () => {
33
+ const api = createSessionApi({baseUrl: "http://localhost:9999"});
34
+ const withEndpoints = api.injectEndpoints({
35
+ endpoints: (build) => ({
36
+ getThing: build.query<unknown, void>({query: () => "/thing"}),
37
+ getWithParams: build.query<unknown, void>({
38
+ query: () => ({params: {ids: {$in: [1, 2]}, status: "active"}, url: "/things"}),
39
+ }),
40
+ }),
41
+ overrideExisting: true,
42
+ });
43
+ const store = configureStore({
44
+ middleware: (getDefaultMiddleware) => getDefaultMiddleware().concat(api.middleware),
45
+ reducer: {[api.reducerPath]: api.reducer},
46
+ });
47
+ return {api, store, withEndpoints};
48
+ };
49
+
50
+ describe("createSessionApi", () => {
51
+ afterEach(() => {
52
+ global.fetch = originalFetch;
53
+ });
54
+
55
+ it("defaults reducerPath to terreno-session", () => {
56
+ const api = createSessionApi();
57
+ expect(api.reducerPath).toBe("terreno-session");
58
+ });
59
+
60
+ it("uses a custom reducerPath when provided", () => {
61
+ const api = createSessionApi({reducerPath: "admin-session"});
62
+ expect(api.reducerPath).toBe("admin-session");
63
+ });
64
+
65
+ describe("responseHandler", () => {
66
+ it("returns null for a 204 No Content response", async () => {
67
+ setFetch(() => new Response(null, {status: 204}));
68
+ const {store, withEndpoints} = buildStore();
69
+
70
+ const result = await store.dispatch(withEndpoints.endpoints.getThing.initiate());
71
+
72
+ expect(result.error).toBeUndefined();
73
+ expect(result.data).toBeNull();
74
+ });
75
+
76
+ it("returns the whole payload for list responses containing `more`", async () => {
77
+ const payload = {data: [{id: "1"}], more: false, page: 1};
78
+ setFetch(() => jsonResponse(payload));
79
+ const {store, withEndpoints} = buildStore();
80
+
81
+ const result = await store.dispatch(withEndpoints.endpoints.getThing.initiate());
82
+
83
+ expect(result.data).toEqual(payload);
84
+ });
85
+
86
+ it("unwraps the `data` envelope for single-resource responses", async () => {
87
+ setFetch(() => jsonResponse({data: {id: "42", name: "answer"}}));
88
+ const {store, withEndpoints} = buildStore();
89
+
90
+ const result = await store.dispatch(withEndpoints.endpoints.getThing.initiate());
91
+
92
+ expect(result.data).toEqual({id: "42", name: "answer"});
93
+ });
94
+
95
+ it("returns the raw result when there is no `data` envelope or `more` field", async () => {
96
+ setFetch(() => jsonResponse({id: "7", name: "raw"}));
97
+ const {store, withEndpoints} = buildStore();
98
+
99
+ const result = await store.dispatch(withEndpoints.endpoints.getThing.initiate());
100
+
101
+ expect(result.data).toEqual({id: "7", name: "raw"});
102
+ });
103
+ });
104
+
105
+ it("serializes nested query params with qs", async () => {
106
+ setFetch(() => jsonResponse({id: "1"}));
107
+ const {store, withEndpoints} = buildStore();
108
+
109
+ await store.dispatch(withEndpoints.endpoints.getWithParams.initiate());
110
+
111
+ const requestedUrl = recorded.urls[0];
112
+ expect(requestedUrl).toContain("ids%5B%24in%5D%5B0%5D=1");
113
+ expect(requestedUrl).toContain("status=active");
114
+ });
115
+ });
@@ -0,0 +1,390 @@
1
+ import {describe, expect, it, mock} from "bun:test";
2
+ import {configureStore} from "@reduxjs/toolkit";
3
+ import {act, renderHook} from "@testing-library/react-native";
4
+ import React from "react";
5
+ import {Provider} from "react-redux";
6
+ import type {Socket} from "socket.io-client";
7
+
8
+ import type {RealtimeEvent} from "./realtime";
9
+ import {useSyncConnection} from "./sync";
10
+
11
+ const REDUCER_PATH = "testApi";
12
+
13
+ type SocketHandler = (arg?: unknown) => void;
14
+
15
+ interface FakeSocket {
16
+ connected: boolean;
17
+ emit: ReturnType<typeof mock>;
18
+ on: ReturnType<typeof mock>;
19
+ off: ReturnType<typeof mock>;
20
+ trigger: (event: string, arg?: unknown) => void;
21
+ }
22
+
23
+ const createFakeSocket = (connected: boolean): FakeSocket => {
24
+ const handlers: Record<string, SocketHandler[]> = {};
25
+ return {
26
+ connected,
27
+ emit: mock(() => {}),
28
+ off: mock((event: string, cb: SocketHandler) => {
29
+ handlers[event] = (handlers[event] ?? []).filter((h) => h !== cb);
30
+ }),
31
+ on: mock((event: string, cb: SocketHandler) => {
32
+ handlers[event] = handlers[event] ?? [];
33
+ handlers[event].push(cb);
34
+ }),
35
+ trigger: (event: string, arg?: unknown) => {
36
+ for (const cb of handlers[event] ?? []) {
37
+ cb(arg);
38
+ }
39
+ },
40
+ };
41
+ };
42
+
43
+ interface QueryEntry {
44
+ status: string;
45
+ endpointName: string;
46
+ originalArgs: unknown;
47
+ data?: Record<string, unknown>;
48
+ }
49
+
50
+ const createApi = (queries: Record<string, QueryEntry> | null) => {
51
+ const dataByEndpoint: Record<string, Record<string, unknown>> = {};
52
+ if (queries) {
53
+ for (const key of Object.keys(queries)) {
54
+ const entry = queries[key];
55
+ if (entry.data) {
56
+ dataByEndpoint[entry.endpointName] = entry.data;
57
+ }
58
+ }
59
+ }
60
+
61
+ const invalidateTags = mock((tags: string[]) => ({payload: tags, type: "api/invalidateTags"}));
62
+ const updateQueryData = mock(
63
+ (endpointName: string, _args: unknown, recipe: (draft: Record<string, unknown>) => void) => {
64
+ const draft = dataByEndpoint[endpointName];
65
+ if (draft) {
66
+ recipe(draft);
67
+ }
68
+ return {type: "api/updateQueryData"};
69
+ }
70
+ );
71
+
72
+ const api = {
73
+ reducerPath: REDUCER_PATH,
74
+ util: {invalidateTags, updateQueryData},
75
+ };
76
+
77
+ const store = configureStore({
78
+ middleware: (getDefaultMiddleware) =>
79
+ getDefaultMiddleware({immutableCheck: false, serializableCheck: false}),
80
+ reducer: {
81
+ [REDUCER_PATH]: (state = {queries: queries ?? undefined}) => state,
82
+ },
83
+ });
84
+
85
+ return {api, invalidateTags, store, updateQueryData};
86
+ };
87
+
88
+ const renderSync = (
89
+ socket: FakeSocket | null,
90
+ api: ReturnType<typeof createApi>["api"],
91
+ store: ReturnType<typeof configureStore>,
92
+ options: {tagTypes?: string[]; debug?: boolean} = {}
93
+ ) => {
94
+ const Wrapper: React.FC<{children: React.ReactNode}> = ({children}) =>
95
+ React.createElement(Provider, {children, store});
96
+ return renderHook(
97
+ () =>
98
+ useSyncConnection({
99
+ api,
100
+ debug: options.debug ?? true,
101
+ socket: socket as unknown as Socket,
102
+ tagTypes: options.tagTypes ?? ["todos"],
103
+ }),
104
+ {wrapper: Wrapper}
105
+ );
106
+ };
107
+
108
+ const syncEvent = (overrides: Partial<RealtimeEvent>): RealtimeEvent =>
109
+ ({
110
+ collection: "todos",
111
+ id: "1",
112
+ method: "update",
113
+ ...overrides,
114
+ }) as RealtimeEvent;
115
+
116
+ describe("useSyncConnection", () => {
117
+ it("does nothing when there is no socket", () => {
118
+ const {store, api} = createApi(null);
119
+ const {result} = renderSync(null, api, store);
120
+ expect(result.current).toBeUndefined();
121
+ });
122
+
123
+ it("subscribes to model rooms when the socket is already connected", () => {
124
+ const socket = createFakeSocket(true);
125
+ const {store, api} = createApi({});
126
+ const {unmount} = renderSync(socket, api, store, {tagTypes: ["todos", "users"]});
127
+
128
+ expect(socket.emit).toHaveBeenCalledWith("subscribe:model", "todos");
129
+ expect(socket.emit).toHaveBeenCalledWith("subscribe:model", "users");
130
+ expect(socket.on).toHaveBeenCalledWith("connect", expect.any(Function));
131
+ expect(socket.on).toHaveBeenCalledWith("sync", expect.any(Function));
132
+
133
+ unmount();
134
+ expect(socket.emit).toHaveBeenCalledWith("unsubscribe:model", "todos");
135
+ expect(socket.off).toHaveBeenCalledWith("sync", expect.any(Function));
136
+ });
137
+
138
+ it("subscribes on the connect event when not initially connected", () => {
139
+ const socket = createFakeSocket(false);
140
+ const {store, api} = createApi({});
141
+ const {unmount} = renderSync(socket, api, store);
142
+
143
+ expect(socket.emit).not.toHaveBeenCalledWith("subscribe:model", "todos");
144
+
145
+ act(() => {
146
+ socket.trigger("connect");
147
+ });
148
+ expect(socket.emit).toHaveBeenCalledWith("subscribe:model", "todos");
149
+
150
+ // Not connected on cleanup, so no unsubscribe emit.
151
+ socket.emit.mockClear();
152
+ unmount();
153
+ expect(socket.emit).not.toHaveBeenCalledWith("unsubscribe:model", "todos");
154
+ });
155
+
156
+ it("ignores events for collections that are not tracked", () => {
157
+ const socket = createFakeSocket(true);
158
+ const {store, api, invalidateTags} = createApi({});
159
+ renderSync(socket, api, store);
160
+
161
+ act(() => {
162
+ socket.trigger("sync", syncEvent({collection: "other", method: "create"}));
163
+ });
164
+ expect(invalidateTags).not.toHaveBeenCalled();
165
+ });
166
+
167
+ it("invalidates tags for create events", () => {
168
+ const socket = createFakeSocket(true);
169
+ const {store, api, invalidateTags} = createApi({});
170
+ renderSync(socket, api, store);
171
+
172
+ act(() => {
173
+ socket.trigger("sync", syncEvent({method: "create"}));
174
+ });
175
+ expect(invalidateTags).toHaveBeenCalledWith(["todos"]);
176
+ });
177
+
178
+ it("invalidates tags for update events without data", () => {
179
+ const socket = createFakeSocket(true);
180
+ const {store, api, invalidateTags} = createApi({});
181
+ renderSync(socket, api, store);
182
+
183
+ act(() => {
184
+ socket.trigger("sync", syncEvent({data: undefined, method: "update"}));
185
+ });
186
+ expect(invalidateTags).toHaveBeenCalledWith(["todos"]);
187
+ });
188
+
189
+ it("invalidates tags for update events when there are no cached queries", () => {
190
+ const socket = createFakeSocket(true);
191
+ const {store, api, invalidateTags} = createApi(null);
192
+ renderSync(socket, api, store);
193
+
194
+ act(() => {
195
+ socket.trigger("sync", syncEvent({data: {name: "new"}, method: "update"}));
196
+ });
197
+ expect(invalidateTags).toHaveBeenCalledWith(["todos"]);
198
+ });
199
+
200
+ it("patches a matching entity inside a cached list query on update", () => {
201
+ const socket = createFakeSocket(true);
202
+ const queries = {
203
+ "getTodos()": {
204
+ data: {data: [{_id: "1", name: "old", updated: "2026-01-01T00:00:00.000Z"}], total: 1},
205
+ endpointName: "getTodos",
206
+ originalArgs: undefined,
207
+ status: "fulfilled",
208
+ },
209
+ };
210
+ const {store, api, updateQueryData, invalidateTags} = createApi(queries);
211
+ renderSync(socket, api, store);
212
+
213
+ act(() => {
214
+ socket.trigger(
215
+ "sync",
216
+ syncEvent({data: {name: "new", updated: "2026-02-01T00:00:00.000Z"}, method: "update"})
217
+ );
218
+ });
219
+
220
+ expect(updateQueryData).toHaveBeenCalled();
221
+ expect(queries["getTodos()"].data.data[0].name).toBe("new");
222
+ expect(invalidateTags).not.toHaveBeenCalled();
223
+ });
224
+
225
+ it("skips stale updates in a cached list query", () => {
226
+ const socket = createFakeSocket(true);
227
+ const queries = {
228
+ "getTodos()": {
229
+ data: {data: [{_id: "1", name: "current", updated: "2026-03-01T00:00:00.000Z"}], total: 1},
230
+ endpointName: "getTodos",
231
+ originalArgs: undefined,
232
+ status: "fulfilled",
233
+ },
234
+ };
235
+ const {store, api} = createApi(queries);
236
+ renderSync(socket, api, store);
237
+
238
+ act(() => {
239
+ socket.trigger(
240
+ "sync",
241
+ syncEvent({data: {name: "stale", updated: "2026-01-01T00:00:00.000Z"}, method: "update"})
242
+ );
243
+ });
244
+
245
+ expect(queries["getTodos()"].data.data[0].name).toBe("current");
246
+ });
247
+
248
+ it("patches a matching single-entity cached query on update", () => {
249
+ const socket = createFakeSocket(true);
250
+ const queries = {
251
+ "getTodoById(1)": {
252
+ data: {_id: "1", name: "old", updated: "2026-01-01T00:00:00.000Z"},
253
+ endpointName: "getTodoById",
254
+ originalArgs: {id: "1"},
255
+ status: "fulfilled",
256
+ },
257
+ };
258
+ const {store, api, updateQueryData} = createApi(queries);
259
+ renderSync(socket, api, store);
260
+
261
+ act(() => {
262
+ socket.trigger(
263
+ "sync",
264
+ syncEvent({data: {name: "new", updated: "2026-02-01T00:00:00.000Z"}, method: "update"})
265
+ );
266
+ });
267
+
268
+ expect(updateQueryData).toHaveBeenCalled();
269
+ expect(queries["getTodoById(1)"].data.name).toBe("new");
270
+ });
271
+
272
+ it("skips stale updates for a single-entity cached query", () => {
273
+ const socket = createFakeSocket(true);
274
+ const queries = {
275
+ "getTodoById(1)": {
276
+ data: {_id: "1", name: "current", updated: "2026-03-01T00:00:00.000Z"},
277
+ endpointName: "getTodoById",
278
+ originalArgs: {id: "1"},
279
+ status: "fulfilled",
280
+ },
281
+ };
282
+ const {store, api, invalidateTags} = createApi(queries);
283
+ renderSync(socket, api, store);
284
+
285
+ act(() => {
286
+ socket.trigger(
287
+ "sync",
288
+ syncEvent({data: {name: "stale", updated: "2026-01-01T00:00:00.000Z"}, method: "update"})
289
+ );
290
+ });
291
+
292
+ expect(queries["getTodoById(1)"].data.name).toBe("current");
293
+ expect(invalidateTags).toHaveBeenCalledWith(["todos"]);
294
+ });
295
+
296
+ it("invalidates tags on update when no cached query matches", () => {
297
+ const socket = createFakeSocket(true);
298
+ const queries = {
299
+ "getOthers()": {
300
+ data: {data: [{_id: "999", name: "unrelated"}]},
301
+ endpointName: "getOthers",
302
+ originalArgs: undefined,
303
+ status: "fulfilled",
304
+ },
305
+ "pending()": {
306
+ data: {data: [{_id: "1"}]},
307
+ endpointName: "pending",
308
+ originalArgs: undefined,
309
+ status: "pending",
310
+ },
311
+ };
312
+ const {store, api, invalidateTags} = createApi(queries);
313
+ renderSync(socket, api, store);
314
+
315
+ act(() => {
316
+ socket.trigger("sync", syncEvent({data: {name: "new"}, method: "update"}));
317
+ });
318
+
319
+ expect(invalidateTags).toHaveBeenCalledWith(["todos"]);
320
+ });
321
+
322
+ it("removes a deleted entity from cached list queries and decrements total", () => {
323
+ const socket = createFakeSocket(true);
324
+ const queries = {
325
+ "getTodos()": {
326
+ data: {
327
+ data: [
328
+ {_id: "1", name: "a"},
329
+ {_id: "2", name: "b"},
330
+ ],
331
+ total: 2,
332
+ },
333
+ endpointName: "getTodos",
334
+ originalArgs: undefined,
335
+ status: "fulfilled",
336
+ },
337
+ };
338
+ const {store, api, updateQueryData} = createApi(queries);
339
+ renderSync(socket, api, store);
340
+
341
+ act(() => {
342
+ socket.trigger("sync", syncEvent({method: "delete"}));
343
+ });
344
+
345
+ expect(updateQueryData).toHaveBeenCalled();
346
+ expect(queries["getTodos()"].data.data).toHaveLength(1);
347
+ expect(queries["getTodos()"].data.total).toBe(1);
348
+ });
349
+
350
+ it("invalidates tags on delete when there are no cached queries", () => {
351
+ const socket = createFakeSocket(true);
352
+ const {store, api, invalidateTags} = createApi(null);
353
+ renderSync(socket, api, store);
354
+
355
+ act(() => {
356
+ socket.trigger("sync", syncEvent({method: "delete"}));
357
+ });
358
+ expect(invalidateTags).toHaveBeenCalledWith(["todos"]);
359
+ });
360
+
361
+ it("invalidates tags on delete when no cached query matches", () => {
362
+ const socket = createFakeSocket(true);
363
+ const queries = {
364
+ "getOthers()": {
365
+ data: {data: [{_id: "999"}]},
366
+ endpointName: "getOthers",
367
+ originalArgs: undefined,
368
+ status: "fulfilled",
369
+ },
370
+ };
371
+ const {store, api, invalidateTags} = createApi(queries);
372
+ renderSync(socket, api, store);
373
+
374
+ act(() => {
375
+ socket.trigger("sync", syncEvent({method: "delete"}));
376
+ });
377
+ expect(invalidateTags).toHaveBeenCalledWith(["todos"]);
378
+ });
379
+
380
+ it("does not log sync details when debug is disabled", () => {
381
+ const socket = createFakeSocket(true);
382
+ const {store, api, invalidateTags} = createApi({});
383
+ renderSync(socket, api, store, {debug: false});
384
+
385
+ act(() => {
386
+ socket.trigger("sync", syncEvent({method: "create"}));
387
+ });
388
+ expect(invalidateTags).toHaveBeenCalledWith(["todos"]);
389
+ });
390
+ });
package/src/sync.ts CHANGED
@@ -40,7 +40,8 @@ interface UseSyncConnectionOptions {
40
40
  /** Socket.io client instance (from useSocketConnection) */
41
41
  socket: Socket | null;
42
42
  /** RTK Query API instance (the enhanced API with tag types) */
43
- // biome-ignore lint/suspicious/noExplicitAny: noExplicitAny: RTK Query Api generic requires four app-specific type parameters
43
+ // noExplicitAny: RTK Query Api generic requires four app-specific type parameters
44
+ // biome-ignore lint/suspicious/noExplicitAny: RTK Query Api generic requires four app-specific type parameters
44
45
  api: any;
45
46
  /** Tag types to listen for (e.g. ["todos", "users"]) — these should match the collection field in events */
46
47
  tagTypes: string[];