@terreno/rtk 0.13.3 → 0.14.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.
Files changed (163) hide show
  1. package/dist/authSlice.d.ts +4 -4
  2. package/dist/authSlice.d.ts.map +1 -1
  3. package/dist/authSlice.js +7 -7
  4. package/dist/authSlice.js.map +1 -1
  5. package/dist/authSlice.test.js +2 -10
  6. package/dist/authSlice.test.js.map +1 -1
  7. package/dist/authSliceNative.test.js +11 -3
  8. package/dist/authSliceNative.test.js.map +1 -1
  9. package/dist/betterAuthSlice.d.ts.map +1 -1
  10. package/dist/betterAuthSlice.js +3 -2
  11. package/dist/betterAuthSlice.js.map +1 -1
  12. package/dist/betterAuthSlice.test.js +1 -25
  13. package/dist/betterAuthSlice.test.js.map +1 -1
  14. package/dist/constants.d.ts +3 -0
  15. package/dist/constants.d.ts.map +1 -1
  16. package/dist/constants.js +11 -3
  17. package/dist/constants.js.map +1 -1
  18. package/dist/constants.test.js +85 -2
  19. package/dist/constants.test.js.map +1 -1
  20. package/dist/emptyApi.d.ts +3 -1
  21. package/dist/emptyApi.d.ts.map +1 -1
  22. package/dist/emptyApi.js +7 -2
  23. package/dist/emptyApi.js.map +1 -1
  24. package/dist/index.d.ts +8 -0
  25. package/dist/index.d.ts.map +1 -1
  26. package/dist/index.js +8 -0
  27. package/dist/index.js.map +1 -1
  28. package/dist/isolated/constants.isolated.js +7 -1
  29. package/dist/isolated/constants.isolated.js.map +1 -1
  30. package/dist/isolated/offlineMiddleware.isolated.d.ts +2 -0
  31. package/dist/isolated/offlineMiddleware.isolated.d.ts.map +1 -0
  32. package/dist/isolated/offlineMiddleware.isolated.js +516 -0
  33. package/dist/isolated/offlineMiddleware.isolated.js.map +1 -0
  34. package/dist/isolated/offlineMiddleware.native.isolated.d.ts +2 -0
  35. package/dist/isolated/offlineMiddleware.native.isolated.d.ts.map +1 -0
  36. package/dist/isolated/offlineMiddleware.native.isolated.js +70 -0
  37. package/dist/isolated/offlineMiddleware.native.isolated.js.map +1 -0
  38. package/dist/isolated/useUpgradeCheck.mobile.isolated.d.ts +2 -0
  39. package/dist/isolated/useUpgradeCheck.mobile.isolated.d.ts.map +1 -0
  40. package/dist/{useUpgradeCheck.mobile.test.js → isolated/useUpgradeCheck.mobile.isolated.js} +13 -5
  41. package/dist/isolated/useUpgradeCheck.mobile.isolated.js.map +1 -0
  42. package/dist/isolated/useUpgradeCheck.web.isolated.d.ts +2 -0
  43. package/dist/isolated/useUpgradeCheck.web.isolated.d.ts.map +1 -0
  44. package/dist/{useUpgradeCheck.test.js → isolated/useUpgradeCheck.web.isolated.js} +37 -7
  45. package/dist/isolated/useUpgradeCheck.web.isolated.js.map +1 -0
  46. package/dist/mongooseSlice.d.ts +3 -2
  47. package/dist/mongooseSlice.d.ts.map +1 -1
  48. package/dist/mongooseSlice.js +3 -4
  49. package/dist/mongooseSlice.js.map +1 -1
  50. package/dist/mongooseSlice.test.js +1 -1
  51. package/dist/mongooseSlice.test.js.map +1 -1
  52. package/dist/offlineGate.d.ts +8 -0
  53. package/dist/offlineGate.d.ts.map +1 -0
  54. package/dist/offlineGate.js +20 -0
  55. package/dist/offlineGate.js.map +1 -0
  56. package/dist/offlineGate.test.d.ts +2 -0
  57. package/dist/offlineGate.test.d.ts.map +1 -0
  58. package/dist/offlineGate.test.js +46 -0
  59. package/dist/offlineGate.test.js.map +1 -0
  60. package/dist/offlineMiddleware.d.ts +46 -0
  61. package/dist/offlineMiddleware.d.ts.map +1 -0
  62. package/dist/offlineMiddleware.js +589 -0
  63. package/dist/offlineMiddleware.js.map +1 -0
  64. package/dist/offlineMiddleware.test.d.ts +2 -0
  65. package/dist/offlineMiddleware.test.d.ts.map +1 -0
  66. package/dist/offlineMiddleware.test.js +145 -0
  67. package/dist/offlineMiddleware.test.js.map +1 -0
  68. package/dist/offlineSlice.d.ts +232 -0
  69. package/dist/offlineSlice.d.ts.map +1 -0
  70. package/dist/offlineSlice.js +65 -0
  71. package/dist/offlineSlice.js.map +1 -0
  72. package/dist/offlineSlice.test.d.ts +2 -0
  73. package/dist/offlineSlice.test.d.ts.map +1 -0
  74. package/dist/offlineSlice.test.js +153 -0
  75. package/dist/offlineSlice.test.js.map +1 -0
  76. package/dist/realtime.d.ts +123 -0
  77. package/dist/realtime.d.ts.map +1 -0
  78. package/dist/realtime.js +299 -0
  79. package/dist/realtime.js.map +1 -0
  80. package/dist/realtime.test.d.ts +2 -0
  81. package/dist/realtime.test.d.ts.map +1 -0
  82. package/dist/realtime.test.js +339 -0
  83. package/dist/realtime.test.js.map +1 -0
  84. package/dist/socket.d.ts.map +1 -1
  85. package/dist/socket.js +29 -28
  86. package/dist/socket.js.map +1 -1
  87. package/dist/sync.d.ts +35 -0
  88. package/dist/sync.d.ts.map +1 -0
  89. package/dist/sync.js +205 -0
  90. package/dist/sync.js.map +1 -0
  91. package/dist/tagGenerator.d.ts +20 -1
  92. package/dist/tagGenerator.d.ts.map +1 -1
  93. package/dist/tagGenerator.js +4 -12
  94. package/dist/tagGenerator.js.map +1 -1
  95. package/dist/useFeatureFlags.d.ts +4 -2
  96. package/dist/useFeatureFlags.d.ts.map +1 -1
  97. package/dist/useFeatureFlags.js +3 -5
  98. package/dist/useFeatureFlags.js.map +1 -1
  99. package/dist/useOfflineStatus.d.ts +33 -0
  100. package/dist/useOfflineStatus.d.ts.map +1 -0
  101. package/dist/useOfflineStatus.js +43 -0
  102. package/dist/useOfflineStatus.js.map +1 -0
  103. package/dist/useOfflineStatus.test.d.ts +2 -0
  104. package/dist/useOfflineStatus.test.d.ts.map +1 -0
  105. package/dist/useOfflineStatus.test.js +119 -0
  106. package/dist/useOfflineStatus.test.js.map +1 -0
  107. package/dist/useRealtimeDebug.d.ts +6 -0
  108. package/dist/useRealtimeDebug.d.ts.map +1 -0
  109. package/dist/useRealtimeDebug.js +41 -0
  110. package/dist/useRealtimeDebug.js.map +1 -0
  111. package/dist/useServerStatus.d.ts +29 -0
  112. package/dist/useServerStatus.d.ts.map +1 -0
  113. package/dist/useServerStatus.js +89 -0
  114. package/dist/useServerStatus.js.map +1 -0
  115. package/dist/useUpgradeCheck.d.ts +7 -3
  116. package/dist/useUpgradeCheck.d.ts.map +1 -1
  117. package/dist/useUpgradeCheck.js +27 -13
  118. package/dist/useUpgradeCheck.js.map +1 -1
  119. package/package.json +2 -2
  120. package/src/authSlice.test.ts +2 -11
  121. package/src/authSlice.ts +9 -9
  122. package/src/authSliceNative.test.ts +11 -4
  123. package/src/betterAuthSlice.test.ts +1 -25
  124. package/src/betterAuthSlice.ts +3 -2
  125. package/src/constants.test.ts +99 -1
  126. package/src/constants.ts +14 -3
  127. package/src/emptyApi.ts +11 -3
  128. package/src/index.ts +8 -0
  129. package/src/isolated/constants.isolated.ts +7 -3
  130. package/src/isolated/offlineMiddleware.isolated.ts +696 -0
  131. package/src/isolated/offlineMiddleware.native.isolated.ts +80 -0
  132. package/src/{useUpgradeCheck.mobile.test.ts → isolated/useUpgradeCheck.mobile.isolated.ts} +13 -4
  133. package/src/{useUpgradeCheck.test.ts → isolated/useUpgradeCheck.web.isolated.ts} +48 -6
  134. package/src/mongooseSlice.test.ts +1 -1
  135. package/src/mongooseSlice.ts +4 -5
  136. package/src/offlineGate.test.ts +55 -0
  137. package/src/offlineGate.ts +25 -0
  138. package/src/offlineMiddleware.test.ts +187 -0
  139. package/src/offlineMiddleware.ts +716 -0
  140. package/src/offlineSlice.test.ts +204 -0
  141. package/src/offlineSlice.ts +130 -0
  142. package/src/realtime.test.ts +404 -0
  143. package/src/realtime.ts +408 -0
  144. package/src/socket.ts +33 -32
  145. package/src/sync.ts +310 -0
  146. package/src/tagGenerator.ts +30 -18
  147. package/src/useFeatureFlags.ts +6 -6
  148. package/src/useOfflineStatus.test.ts +157 -0
  149. package/src/useOfflineStatus.ts +82 -0
  150. package/src/useRealtimeDebug.ts +51 -0
  151. package/src/useServerStatus.ts +122 -0
  152. package/src/useUpgradeCheck.ts +35 -13
  153. package/dist/isolated/useUpgradeCheck.isolated.d.ts +0 -2
  154. package/dist/isolated/useUpgradeCheck.isolated.d.ts.map +0 -1
  155. package/dist/isolated/useUpgradeCheck.isolated.js +0 -153
  156. package/dist/isolated/useUpgradeCheck.isolated.js.map +0 -1
  157. package/dist/useUpgradeCheck.mobile.test.d.ts +0 -2
  158. package/dist/useUpgradeCheck.mobile.test.d.ts.map +0 -1
  159. package/dist/useUpgradeCheck.mobile.test.js.map +0 -1
  160. package/dist/useUpgradeCheck.test.d.ts +0 -2
  161. package/dist/useUpgradeCheck.test.d.ts.map +0 -1
  162. package/dist/useUpgradeCheck.test.js.map +0 -1
  163. package/src/isolated/useUpgradeCheck.isolated.ts +0 -201
package/src/sync.ts ADDED
@@ -0,0 +1,310 @@
1
+ import {DateTime} from "luxon";
2
+ import {useEffect, useRef} from "react";
3
+ import {useDispatch, useStore} from "react-redux";
4
+ import type {Socket} from "socket.io-client";
5
+
6
+ import {isWebsocketsDebugEnabled, logSocket} from "./constants";
7
+ import type {RealtimeEvent} from "./realtime";
8
+
9
+ interface DocumentItem {
10
+ _id?: string;
11
+ id?: string;
12
+ updated?: string;
13
+ [key: string]: unknown;
14
+ }
15
+
16
+ interface CacheEntryData {
17
+ data?: DocumentItem[];
18
+ _id?: string;
19
+ id?: string;
20
+ updated?: string;
21
+ total?: number;
22
+ [key: string]: unknown;
23
+ }
24
+
25
+ interface QueryCacheEntry {
26
+ status: string;
27
+ data?: CacheEntryData;
28
+ endpointName: string;
29
+ originalArgs: unknown;
30
+ }
31
+
32
+ interface RtkApiState {
33
+ queries?: Record<string, QueryCacheEntry>;
34
+ }
35
+
36
+ // noExplicitAny: RTK Query's Api type requires four generic parameters (BaseQuery, Definitions,
37
+ // ReducerPath, TagTypes) that vary per consumer. A structural interface would lose dispatch
38
+ // compatibility for thunk-returning util methods like updateQueryData.
39
+ interface UseSyncConnectionOptions {
40
+ /** Socket.io client instance (from useSocketConnection) */
41
+ socket: Socket | null;
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
44
+ api: any;
45
+ /** Tag types to listen for (e.g. ["todos", "users"]) — these should match the collection field in events */
46
+ tagTypes: string[];
47
+ /** Enable debug logging */
48
+ debug?: boolean;
49
+ }
50
+
51
+ /**
52
+ * Hook that connects WebSocket sync events to RTK Query's cache.
53
+ *
54
+ * For **create** events: invalidates cache tags to trigger refetch of list queries.
55
+ * For **update** events: patches entities in-place in cached queries, falling back to tag invalidation.
56
+ * For **delete** events: removes entities from cached list queries, falling back to tag invalidation.
57
+ *
58
+ * Automatically subscribes to model rooms for each tagType when the socket connects,
59
+ * so models using `roomStrategy: "model"` will work without additional setup.
60
+ *
61
+ * @example
62
+ * ```typescript
63
+ * const { socket } = useSocketConnection({ ... });
64
+ *
65
+ * useSyncConnection({
66
+ * socket,
67
+ * api: terrenoApi,
68
+ * tagTypes: ['todos', 'users'],
69
+ * });
70
+ * ```
71
+ */
72
+ export const useSyncConnection = ({
73
+ socket,
74
+ api,
75
+ tagTypes,
76
+ debug = false,
77
+ }: UseSyncConnectionOptions): void => {
78
+ const dispatch = useDispatch();
79
+ const store = useStore();
80
+ const tagTypesRef = useRef(tagTypes);
81
+ const apiRef = useRef(api);
82
+ tagTypesRef.current = tagTypes;
83
+ apiRef.current = api;
84
+
85
+ // Auto-subscribe to model rooms for each tagType when socket connects
86
+ useEffect(() => {
87
+ if (!socket) {
88
+ return;
89
+ }
90
+
91
+ const subscribeToModels = (): void => {
92
+ for (const tag of tagTypesRef.current) {
93
+ socket.emit("subscribe:model", tag);
94
+ }
95
+ };
96
+
97
+ // Subscribe on connect and when already connected
98
+ if (socket.connected) {
99
+ subscribeToModels();
100
+ }
101
+ socket.on("connect", subscribeToModels);
102
+
103
+ return (): void => {
104
+ socket.off("connect", subscribeToModels);
105
+ // Unsubscribe from model rooms on cleanup
106
+ if (socket.connected) {
107
+ for (const tag of tagTypesRef.current) {
108
+ socket.emit("unsubscribe:model", tag);
109
+ }
110
+ }
111
+ };
112
+ }, [socket]);
113
+
114
+ // Listen for sync events and update RTK Query cache
115
+ useEffect(() => {
116
+ if (!socket) {
117
+ return;
118
+ }
119
+
120
+ const log = (message: string): void => {
121
+ if (isDebug) {
122
+ logSocket(true, message);
123
+ }
124
+ };
125
+
126
+ const logEvent = (event: RealtimeEvent): void => {
127
+ if (!isDebug) {
128
+ return;
129
+ }
130
+ logSocket(true, `sync event: ${JSON.stringify(event)}`);
131
+ };
132
+
133
+ const isDebug = debug ?? isWebsocketsDebugEnabled();
134
+
135
+ const getApiQueries = (): Record<string, QueryCacheEntry> | null => {
136
+ try {
137
+ const state = store.getState() as Record<string, RtkApiState>;
138
+ return state?.[apiRef.current.reducerPath]?.queries ?? null;
139
+ } catch {
140
+ return null;
141
+ }
142
+ };
143
+
144
+ const handleSync = (event: RealtimeEvent): void => {
145
+ const {collection, method, id, data} = event;
146
+ const currentApi = apiRef.current;
147
+
148
+ // Only process events for collections we care about
149
+ if (!tagTypesRef.current.includes(collection)) {
150
+ log(`Ignoring event for collection: ${collection}`);
151
+ return;
152
+ }
153
+
154
+ log(`Received ${method} event for ${collection}/${id}`);
155
+ logEvent(event);
156
+
157
+ switch (method) {
158
+ case "update": {
159
+ if (!data) {
160
+ dispatch(currentApi.util.invalidateTags([collection]));
161
+ break;
162
+ }
163
+
164
+ const queries = getApiQueries();
165
+ if (!queries) {
166
+ dispatch(currentApi.util.invalidateTags([collection]));
167
+ break;
168
+ }
169
+
170
+ let patched = false;
171
+ for (const [_cacheKey, queryEntry] of Object.entries(queries)) {
172
+ const entry = queryEntry as QueryCacheEntry;
173
+ if (entry?.status !== "fulfilled" || !entry?.data) {
174
+ continue;
175
+ }
176
+
177
+ const {endpointName, originalArgs} = entry;
178
+
179
+ // List response: {data: [...], total, page, more}
180
+ if (entry.data?.data && Array.isArray(entry.data.data)) {
181
+ const hasEntity = entry.data.data.some(
182
+ (item: DocumentItem) => item._id === id || item.id === id
183
+ );
184
+ if (hasEntity) {
185
+ dispatch(
186
+ currentApi.util.updateQueryData(
187
+ endpointName,
188
+ originalArgs,
189
+ (draft: CacheEntryData) => {
190
+ if (draft?.data && Array.isArray(draft.data)) {
191
+ const index = draft.data.findIndex(
192
+ (item: DocumentItem) => item._id === id || item.id === id
193
+ );
194
+ if (index !== -1) {
195
+ // Stale event check via updated timestamp
196
+ const cachedUpdated = draft.data[index].updated;
197
+ if (
198
+ cachedUpdated &&
199
+ data.updated &&
200
+ DateTime.fromISO(cachedUpdated) > DateTime.fromISO(data.updated)
201
+ ) {
202
+ log(`Skipping stale update for ${collection}/${id}`);
203
+ return;
204
+ }
205
+ Object.assign(draft.data[index], data);
206
+ }
207
+ }
208
+ }
209
+ )
210
+ );
211
+ patched = true;
212
+ }
213
+ }
214
+ // Single entity response: {_id, ...fields}
215
+ else if (entry.data?._id === id || entry.data?.id === id) {
216
+ const cachedUpdated = entry.data.updated;
217
+ if (
218
+ cachedUpdated &&
219
+ data.updated &&
220
+ DateTime.fromISO(cachedUpdated) > DateTime.fromISO(data.updated)
221
+ ) {
222
+ log(`Skipping stale update for ${collection}/${id}`);
223
+ continue;
224
+ }
225
+ dispatch(
226
+ currentApi.util.updateQueryData(
227
+ endpointName,
228
+ originalArgs,
229
+ (draft: CacheEntryData) => {
230
+ Object.assign(draft, data);
231
+ }
232
+ )
233
+ );
234
+ patched = true;
235
+ }
236
+ }
237
+
238
+ if (!patched) {
239
+ dispatch(currentApi.util.invalidateTags([collection]));
240
+ }
241
+ break;
242
+ }
243
+
244
+ case "delete": {
245
+ const queries = getApiQueries();
246
+ if (!queries) {
247
+ dispatch(currentApi.util.invalidateTags([collection]));
248
+ break;
249
+ }
250
+
251
+ let patched = false;
252
+ for (const [_cacheKey, queryEntry] of Object.entries(queries)) {
253
+ const entry = queryEntry as QueryCacheEntry;
254
+ if (entry?.status !== "fulfilled" || !entry?.data) {
255
+ continue;
256
+ }
257
+
258
+ const {endpointName, originalArgs} = entry;
259
+
260
+ // List response with data array
261
+ if (entry.data?.data && Array.isArray(entry.data.data)) {
262
+ const hasEntity = entry.data.data.some(
263
+ (item: DocumentItem) => item._id === id || item.id === id
264
+ );
265
+ if (hasEntity) {
266
+ dispatch(
267
+ currentApi.util.updateQueryData(
268
+ endpointName,
269
+ originalArgs,
270
+ (draft: CacheEntryData) => {
271
+ if (draft?.data && Array.isArray(draft.data)) {
272
+ draft.data = draft.data.filter(
273
+ (item: DocumentItem) => item._id !== id && item.id !== id
274
+ );
275
+ if (typeof draft.total === "number") {
276
+ draft.total = Math.max(0, draft.total - 1);
277
+ }
278
+ }
279
+ }
280
+ )
281
+ );
282
+ patched = true;
283
+ }
284
+ }
285
+ }
286
+
287
+ if (!patched) {
288
+ dispatch(currentApi.util.invalidateTags([collection]));
289
+ }
290
+ break;
291
+ }
292
+
293
+ case "create": {
294
+ // For creates, we can't determine filter match client-side,
295
+ // so invalidate tags to trigger refetch of list queries
296
+ dispatch(currentApi.util.invalidateTags([collection]));
297
+ break;
298
+ }
299
+ }
300
+ };
301
+
302
+ socket.on("sync", handleSync);
303
+ log("Listening for sync events");
304
+
305
+ return (): void => {
306
+ socket.off("sync", handleSync);
307
+ log("Stopped listening for sync events");
308
+ };
309
+ }, [socket, dispatch, store, debug]);
310
+ };
@@ -1,52 +1,64 @@
1
- // biome-ignore-all lint/suspicious/noExplicitAny: Generics
1
+ // Tag generation utilities for RTK Query endpoint cache invalidation by individual IDs
2
2
 
3
- // use this with enhanceEndpoints since the code generator doesn't invalidate by individual ids,
4
- // only at the full collection level
3
+ interface TagProviderResult {
4
+ data?: Array<{_id: string}>;
5
+ _id?: string;
6
+ }
7
+
8
+ type TagEntry = string | {type: string; id?: string};
9
+ type TagProviderFn = (result: TagProviderResult | null | undefined) => TagEntry[];
10
+
11
+ interface EndpointTagConfig {
12
+ providesTags: TagProviderFn;
13
+ invalidatesTags: TagProviderFn;
14
+ }
15
+
16
+ interface ApiWithEndpoints {
17
+ endpoints: Record<string, unknown>;
18
+ }
5
19
 
6
20
  const providesIdTags =
7
21
  (path: string) =>
8
- (result: any): string[] | [{type: string; id?: string}] =>
9
- result ? [...(result?.data?.map(({_id}: any) => ({id: _id, type: path})) ?? []), path] : [path];
22
+ (result: TagProviderResult | null | undefined): TagEntry[] =>
23
+ result ? [...(result?.data?.map(({_id}) => ({id: _id, type: path})) ?? []), path] : [path];
10
24
 
11
25
  const providesIdTag =
12
26
  (path: string) =>
13
- (result: any): string[] | [{type: string; id?: string}] => {
27
+ (result: TagProviderResult | null | undefined): TagEntry[] => {
14
28
  return result ? [{id: result._id, type: path}] : [path];
15
29
  };
16
30
 
17
31
  const invalidatesIdTags =
18
32
  (path: string) =>
19
- (result: any): string[] | [{type: string; id?: string}] =>
20
- result ? [...(result?.data?.map(({_id}: any) => ({id: _id, type: path})) ?? []), path] : [path];
33
+ (result: TagProviderResult | null | undefined): TagEntry[] =>
34
+ result ? [...(result?.data?.map(({_id}) => ({id: _id, type: path})) ?? []), path] : [path];
21
35
 
36
+ /** Strips CRUD prefixes and "ById" suffix to extract the base resource name. */
22
37
  const cleanEndpointStringToGenerateTag = (string: string): string => {
23
- // Define the prefixes and suffix
24
38
  const prefixes = ["patch", "get", "delete"];
25
39
  const suffix = "ById";
26
40
 
27
- // Create a regular expression to match the prefixes and suffix
28
41
  const prefixPattern = `^(${prefixes.join("|")})`;
29
42
  const suffixPattern = `${suffix}$`;
30
43
  const regex = new RegExp(`${prefixPattern}|${suffixPattern}`, "gi");
31
44
 
32
- // Replace the matched parts and convert to lowercase
33
45
  return string.replace(regex, "")?.toLowerCase();
34
46
  };
35
47
 
36
- export const generateTags = (api: any, tagTypes: string[]): any => {
37
- // take the api, and for each get and list endpoint, generate tags that invalidate the cache by id
38
- // and by the list endpoint
48
+ export const generateTags = (
49
+ api: ApiWithEndpoints,
50
+ tagTypes: string[]
51
+ ): Record<string, EndpointTagConfig> => {
39
52
  const endpoints = api.endpoints;
40
- const tags: any = {};
53
+ const tags: Record<string, Partial<EndpointTagConfig>> = {};
41
54
  Object.keys(endpoints).forEach((endpoint) => {
42
55
  if (endpoint === "getConversations") {
43
- tags[endpoint] = {invalidatesTags: ["conversations", "messages"]};
56
+ tags[endpoint] = {invalidatesTags: (): TagEntry[] => ["conversations", "messages"]};
44
57
  }
45
58
  if (endpoint.toLowerCase().includes("get")) {
46
59
  // List endpoints
47
60
  if (!endpoint.toLowerCase().includes("byid")) {
48
61
  const tag = tagTypes.find((t: string) =>
49
- // remove "get" from the endpoint name and "ById" from the endpoint name
50
62
  t.toLowerCase().includes(cleanEndpointStringToGenerateTag(endpoint))
51
63
  );
52
64
  if (tag) {
@@ -76,5 +88,5 @@ export const generateTags = (api: any, tagTypes: string[]): any => {
76
88
  }
77
89
  }
78
90
  });
79
- return tags;
91
+ return tags as Record<string, EndpointTagConfig>;
80
92
  };
@@ -1,7 +1,9 @@
1
1
  import type {Api} from "@reduxjs/toolkit/query/react";
2
2
  import {useCallback, useEffect, useMemo, useRef} from "react";
3
3
 
4
- type FlagValues = Record<string, boolean | string | null>;
4
+ interface FlagValues {
5
+ [key: string]: boolean | string | null;
6
+ }
5
7
 
6
8
  interface UseFeatureFlagsResult {
7
9
  flags: FlagValues;
@@ -62,13 +64,11 @@ export const resolveFeatureFlagsOptions = (
62
64
  return {basePath, skip};
63
65
  };
64
66
 
65
- // Overloaded signature preserves backwards compatibility with callers that
66
- // pass a string basePath as the second argument.
67
- export function useFeatureFlags(
67
+ export const useFeatureFlags = (
68
68
  // biome-ignore lint/suspicious/noExplicitAny: RTK Query API generic typing is intentionally flexible here.
69
69
  api: Api<any, any, any, any>,
70
70
  basePathOrOptions?: string | UseFeatureFlagsOptions
71
- ): UseFeatureFlagsResult {
71
+ ): UseFeatureFlagsResult => {
72
72
  const {basePath, skip} = resolveFeatureFlagsOptions(basePathOrOptions);
73
73
 
74
74
  const enhancedApi = useMemo(
@@ -157,4 +157,4 @@ export function useFeatureFlags(
157
157
  );
158
158
 
159
159
  return {error, flags, getFlag, getVariant, isLoading, refetch};
160
- }
160
+ };
@@ -0,0 +1,157 @@
1
+ import {beforeEach, describe, expect, it} 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
+
7
+ import {
8
+ addConflict,
9
+ type ConflictRecord,
10
+ enqueue,
11
+ offlineReducer,
12
+ type QueuedMutation,
13
+ setOnlineStatus,
14
+ setSyncing,
15
+ } from "./offlineSlice";
16
+ import {useOfflineStatus} from "./useOfflineStatus";
17
+
18
+ const createTestStore = () =>
19
+ configureStore({
20
+ reducer: {offline: offlineReducer},
21
+ });
22
+
23
+ const createWrapper = (store: ReturnType<typeof createTestStore>) => {
24
+ const Wrapper: React.FC<{children: React.ReactNode}> = ({children}) =>
25
+ React.createElement(Provider, {children, store});
26
+ return Wrapper;
27
+ };
28
+
29
+ describe("useOfflineStatus", () => {
30
+ let store: ReturnType<typeof createTestStore>;
31
+
32
+ beforeEach(() => {
33
+ store = createTestStore();
34
+ });
35
+
36
+ it("returns initial online state", () => {
37
+ const {result} = renderHook(() => useOfflineStatus(), {
38
+ wrapper: createWrapper(store),
39
+ });
40
+
41
+ expect(result.current.isOnline).toBe(true);
42
+ expect(result.current.queueLength).toBe(0);
43
+ expect(result.current.isSyncing).toBe(false);
44
+ expect(result.current.conflicts).toEqual([]);
45
+ expect(result.current.undismissedConflicts).toEqual([]);
46
+ });
47
+
48
+ it("reflects offline state", () => {
49
+ store.dispatch(setOnlineStatus(false));
50
+
51
+ const {result} = renderHook(() => useOfflineStatus(), {
52
+ wrapper: createWrapper(store),
53
+ });
54
+
55
+ expect(result.current.isOnline).toBe(false);
56
+ });
57
+
58
+ it("reports queue length", () => {
59
+ const mutation: QueuedMutation = {
60
+ args: {body: {title: "Test"}},
61
+ endpointName: "postTodos",
62
+ id: "test-1",
63
+ timestamp: "2026-01-01T00:00:00.000Z",
64
+ type: "create",
65
+ };
66
+ store.dispatch(enqueue(mutation));
67
+
68
+ const {result} = renderHook(() => useOfflineStatus(), {
69
+ wrapper: createWrapper(store),
70
+ });
71
+
72
+ expect(result.current.queueLength).toBe(1);
73
+ });
74
+
75
+ it("reports syncing state", () => {
76
+ store.dispatch(setSyncing(true));
77
+
78
+ const {result} = renderHook(() => useOfflineStatus(), {
79
+ wrapper: createWrapper(store),
80
+ });
81
+
82
+ expect(result.current.isSyncing).toBe(true);
83
+ });
84
+
85
+ it("filters undismissed conflicts", () => {
86
+ const conflict: ConflictRecord = {
87
+ args: {id: "abc"},
88
+ dismissed: false,
89
+ endpointName: "patchTodosById",
90
+ id: "conflict-1",
91
+ serverDocument: {title: "Server"},
92
+ timestamp: "2026-01-01T00:00:00.000Z",
93
+ };
94
+ store.dispatch(addConflict(conflict));
95
+
96
+ const {result} = renderHook(() => useOfflineStatus(), {
97
+ wrapper: createWrapper(store),
98
+ });
99
+
100
+ expect(result.current.conflicts).toHaveLength(1);
101
+ expect(result.current.undismissedConflicts).toHaveLength(1);
102
+ });
103
+
104
+ it("dismissConflict dispatches the action", () => {
105
+ const conflict: ConflictRecord = {
106
+ args: {id: "abc"},
107
+ dismissed: false,
108
+ endpointName: "patchTodosById",
109
+ id: "conflict-to-dismiss",
110
+ serverDocument: {title: "Server"},
111
+ timestamp: "2026-01-01T00:00:00.000Z",
112
+ };
113
+ store.dispatch(addConflict(conflict));
114
+
115
+ const {result} = renderHook(() => useOfflineStatus(), {
116
+ wrapper: createWrapper(store),
117
+ });
118
+
119
+ act(() => {
120
+ result.current.dismissConflict("conflict-to-dismiss");
121
+ });
122
+
123
+ expect(result.current.undismissedConflicts).toHaveLength(0);
124
+ expect(result.current.conflicts[0].dismissed).toBe(true);
125
+ });
126
+
127
+ it("clearConflicts removes all conflicts", () => {
128
+ const conflict: ConflictRecord = {
129
+ args: {id: "abc"},
130
+ dismissed: false,
131
+ endpointName: "patchTodosById",
132
+ id: "conflict-clear",
133
+ serverDocument: {title: "Server"},
134
+ timestamp: "2026-01-01T00:00:00.000Z",
135
+ };
136
+ store.dispatch(addConflict(conflict));
137
+
138
+ const {result} = renderHook(() => useOfflineStatus(), {
139
+ wrapper: createWrapper(store),
140
+ });
141
+
142
+ act(() => {
143
+ result.current.clearConflicts();
144
+ });
145
+
146
+ expect(result.current.conflicts).toHaveLength(0);
147
+ });
148
+
149
+ it("isLocalOnly returns true for temp-prefixed IDs", () => {
150
+ const {result} = renderHook(() => useOfflineStatus(), {
151
+ wrapper: createWrapper(store),
152
+ });
153
+
154
+ expect(result.current.isLocalOnly("temp-abc123")).toBe(true);
155
+ expect(result.current.isLocalOnly("507f1f77bcf86cd799439011")).toBe(false);
156
+ });
157
+ });
@@ -0,0 +1,82 @@
1
+ import {useCallback} from "react";
2
+ import {useDispatch, useSelector} from "react-redux";
3
+
4
+ import {
5
+ type ConflictRecord,
6
+ clearConflicts,
7
+ dismissConflict,
8
+ type OfflineState,
9
+ selectConflicts,
10
+ selectIsOnline,
11
+ selectIsSyncing,
12
+ selectQueueLength,
13
+ selectUndismissedConflicts,
14
+ } from "./offlineSlice";
15
+
16
+ export interface OfflineStatus {
17
+ /** Whether the device currently has network connectivity */
18
+ isOnline: boolean;
19
+ /** Number of mutations waiting to be synced */
20
+ queueLength: number;
21
+ /** Whether mutations are currently being replayed to the server */
22
+ isSyncing: boolean;
23
+ /** All conflict records (including dismissed) */
24
+ conflicts: ConflictRecord[];
25
+ /** Conflict records the user hasn't dismissed yet */
26
+ undismissedConflicts: ConflictRecord[];
27
+ /** Dismiss a single conflict notification by ID */
28
+ dismissConflict: (id: string) => void;
29
+ /** Clear all conflict records */
30
+ clearConflicts: () => void;
31
+ /** Returns true if the item exists only locally (not yet synced to server) */
32
+ isLocalOnly: (id: string) => boolean;
33
+ }
34
+
35
+ /**
36
+ * Hook for consuming offline state, sync status, and conflict notifications.
37
+ *
38
+ * Usage:
39
+ * ```typescript
40
+ * const {isOnline, queueLength, isSyncing, undismissedConflicts, dismissConflict} = useOfflineStatus();
41
+ *
42
+ * if (!isOnline) {
43
+ * return <Banner text={`Offline. ${queueLength} changes pending.`} />;
44
+ * }
45
+ * ```
46
+ */
47
+ export const useOfflineStatus = (): OfflineStatus => {
48
+ const dispatch = useDispatch();
49
+ const isOnline = useSelector((state: {offline: OfflineState}) => selectIsOnline(state));
50
+ const queueLength = useSelector((state: {offline: OfflineState}) => selectQueueLength(state));
51
+ const isSyncing = useSelector((state: {offline: OfflineState}) => selectIsSyncing(state));
52
+ const conflicts = useSelector((state: {offline: OfflineState}) => selectConflicts(state));
53
+ const undismissedConflicts = useSelector((state: {offline: OfflineState}) =>
54
+ selectUndismissedConflicts(state)
55
+ );
56
+
57
+ const handleDismissConflict = useCallback(
58
+ (id: string) => {
59
+ dispatch(dismissConflict(id));
60
+ },
61
+ [dispatch]
62
+ );
63
+
64
+ const handleClearConflicts = useCallback(() => {
65
+ dispatch(clearConflicts());
66
+ }, [dispatch]);
67
+
68
+ const isLocalOnly = useCallback((id: string): boolean => {
69
+ return typeof id === "string" && id.startsWith("temp-");
70
+ }, []);
71
+
72
+ return {
73
+ clearConflicts: handleClearConflicts,
74
+ conflicts,
75
+ dismissConflict: handleDismissConflict,
76
+ isLocalOnly,
77
+ isOnline,
78
+ isSyncing,
79
+ queueLength,
80
+ undismissedConflicts,
81
+ };
82
+ };