@oxyhq/auth 2.0.4 → 2.0.5

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 (38) hide show
  1. package/dist/cjs/.tsbuildinfo +1 -1
  2. package/dist/cjs/WebOxyProvider.js +37 -0
  3. package/dist/cjs/hooks/mutations/useAccountMutations.js +185 -43
  4. package/dist/cjs/hooks/queryClient.js +136 -92
  5. package/dist/cjs/hooks/useFileDownloadUrl.js +12 -36
  6. package/dist/cjs/hooks/useSessionSocket.js +81 -94
  7. package/dist/cjs/index.js +1 -2
  8. package/dist/cjs/utils/sessionHelpers.js +3 -1
  9. package/dist/cjs/utils/storageHelpers.js +36 -10
  10. package/dist/esm/.tsbuildinfo +1 -1
  11. package/dist/esm/WebOxyProvider.js +38 -1
  12. package/dist/esm/hooks/mutations/useAccountMutations.js +186 -44
  13. package/dist/esm/hooks/queryClient.js +132 -89
  14. package/dist/esm/hooks/useFileDownloadUrl.js +11 -34
  15. package/dist/esm/hooks/useSessionSocket.js +81 -94
  16. package/dist/esm/index.js +1 -1
  17. package/dist/esm/utils/sessionHelpers.js +3 -1
  18. package/dist/esm/utils/storageHelpers.js +36 -10
  19. package/dist/types/.tsbuildinfo +1 -1
  20. package/dist/types/WebOxyProvider.d.ts +1 -1
  21. package/dist/types/hooks/mutations/useAccountMutations.d.ts +153 -9
  22. package/dist/types/hooks/queries/useAccountQueries.d.ts +11 -7
  23. package/dist/types/hooks/queries/useSecurityQueries.d.ts +2 -2
  24. package/dist/types/hooks/queries/useServicesQueries.d.ts +7 -5
  25. package/dist/types/hooks/queryClient.d.ts +24 -10
  26. package/dist/types/hooks/useAssets.d.ts +1 -1
  27. package/dist/types/hooks/useFileDownloadUrl.d.ts +2 -6
  28. package/dist/types/index.d.ts +1 -1
  29. package/dist/types/utils/sessionHelpers.d.ts +3 -1
  30. package/package.json +22 -3
  31. package/src/WebOxyProvider.tsx +39 -1
  32. package/src/hooks/mutations/useAccountMutations.ts +230 -57
  33. package/src/hooks/queryClient.ts +140 -83
  34. package/src/hooks/useFileDownloadUrl.ts +15 -39
  35. package/src/hooks/useSessionSocket.ts +123 -91
  36. package/src/index.ts +1 -1
  37. package/src/utils/sessionHelpers.ts +3 -1
  38. package/src/utils/storageHelpers.ts +49 -10
@@ -9,7 +9,7 @@ import { jsx as _jsx } from "react/jsx-runtime";
9
9
  import { createContext, useCallback, useContext, useEffect, useMemo, useRef, useState, } from 'react';
10
10
  import { OxyServices, CrossDomainAuth, createAuthManager, } from '@oxyhq/core';
11
11
  import { QueryClientProvider } from '@tanstack/react-query';
12
- import { createQueryClient } from './hooks/queryClient';
12
+ import { attachQueryPersistence, createQueryClient } from './hooks/queryClient';
13
13
  const WebOxyContext = createContext(null);
14
14
  /**
15
15
  * Web-only Oxy Provider
@@ -35,6 +35,29 @@ export function WebOxyProvider({ children, baseURL, authWebUrl, onAuthStateChang
35
35
  const [crossDomainAuth] = useState(() => new CrossDomainAuth(oxyServices));
36
36
  const [authManager] = useState(() => createAuthManager(oxyServices, { autoRefresh: true }));
37
37
  const [queryClient] = useState(() => createQueryClient());
38
+ // Block first render until the persisted localStorage cache has been
39
+ // restored — mirrors the RN OxyProvider pattern. Without this gate the
40
+ // first paint observes an empty cache and any consumer reading
41
+ // `getQueryData(...)` synchronously (or using `placeholderData: 'previous'`
42
+ // gating) misses the persisted blob.
43
+ //
44
+ // Persistence is attached inside the same effect so we can hold a
45
+ // reference to the `restored` promise and only flip `isRestoring` to
46
+ // false once it settles (success OR failure). Detach on unmount so HMR
47
+ // doesn't leak subscriptions.
48
+ const [isRestoring, setIsRestoring] = useState(true);
49
+ useEffect(() => {
50
+ let mounted = true;
51
+ const { restored, unsubscribe } = attachQueryPersistence(queryClient);
52
+ restored.finally(() => {
53
+ if (mounted)
54
+ setIsRestoring(false);
55
+ });
56
+ return () => {
57
+ mounted = false;
58
+ unsubscribe();
59
+ };
60
+ }, [queryClient]);
38
61
  // Auth state
39
62
  const [user, setUser] = useState(null);
40
63
  const [isLoading, setIsLoading] = useState(!skipAutoCheck);
@@ -253,6 +276,20 @@ export function WebOxyProvider({ children, baseURL, authWebUrl, onAuthStateChang
253
276
  signIn, signInWithFedCM, signInWithPopup, signInWithRedirect,
254
277
  signOut, isFedCMSupported, switchSession, clearSessionState,
255
278
  ]);
279
+ // Mirror the RN OxyProvider pattern: don't expose the QueryClient (or
280
+ // mount children) until the persisted cache has been restored. On the
281
+ // web this prevents the first paint from observing an empty
282
+ // localStorage-backed cache, which would otherwise force every
283
+ // identity/session/auth query to refetch from the network even when a
284
+ // fresh blob was available on disk.
285
+ //
286
+ // The restored promise is wired with `.finally(...)` upstream, so this
287
+ // unblocks on both success and failure within typically <50ms (sync
288
+ // localStorage read + JSON.parse). A safety net is unnecessary: the
289
+ // restore promise always settles synchronously after one microtask.
290
+ if (isRestoring) {
291
+ return null;
292
+ }
256
293
  return (_jsx(QueryClientProvider, { client: queryClient, children: _jsx(WebOxyContext.Provider, { value: contextValue, children: children }) }));
257
294
  }
258
295
  /**
@@ -1,6 +1,6 @@
1
1
  import { useMutation, useQueryClient } from '@tanstack/react-query';
2
2
  import { authenticatedApiCall } from '@oxyhq/core';
3
- import { queryKeys, invalidateAccountQueries, invalidateUserQueries } from '../queries/queryKeys';
3
+ import { queryKeys, invalidateAccountQueries, invalidateUserQueries, invalidateSessionQueries } from '../queries/queryKeys';
4
4
  import { useWebOxy } from '../../WebOxyProvider';
5
5
  import { toast } from 'sonner';
6
6
  import { refreshAvatarInStore } from '../../utils/avatarUtils';
@@ -9,7 +9,7 @@ import { useAuthStore } from '../../stores/authStore';
9
9
  * Update user profile with optimistic updates and offline queue support
10
10
  */
11
11
  export const useUpdateProfile = () => {
12
- const { oxyServices, activeSessionId, user } = useWebOxy();
12
+ const { oxyServices, activeSessionId } = useWebOxy();
13
13
  const queryClient = useQueryClient();
14
14
  return useMutation({
15
15
  mutationFn: async (updates) => {
@@ -37,12 +37,30 @@ export const useUpdateProfile = () => {
37
37
  }
38
38
  return { previousUser };
39
39
  },
40
- // On error, rollback
40
+ // On error, rollback ONLY the keys this mutation tried to change
41
41
  onError: (error, updates, context) => {
42
- if (context?.previousUser) {
43
- queryClient.setQueryData(queryKeys.accounts.current(), context.previousUser);
42
+ if (context?.previousUser && updates) {
43
+ const previousUser = context.previousUser;
44
+ const changedKeys = Object.keys(updates);
45
+ const partialRollback = changedKeys.reduce((acc, key) => {
46
+ acc[key] = previousUser[key];
47
+ return acc;
48
+ }, {});
49
+ const current = queryClient.getQueryData(queryKeys.accounts.current());
50
+ if (current) {
51
+ queryClient.setQueryData(queryKeys.accounts.current(), {
52
+ ...current,
53
+ ...partialRollback,
54
+ });
55
+ }
44
56
  if (activeSessionId) {
45
- queryClient.setQueryData(queryKeys.users.profile(activeSessionId), context.previousUser);
57
+ const currentProfile = queryClient.getQueryData(queryKeys.users.profile(activeSessionId));
58
+ if (currentProfile) {
59
+ queryClient.setQueryData(queryKeys.users.profile(activeSessionId), {
60
+ ...currentProfile,
61
+ ...partialRollback,
62
+ });
63
+ }
46
64
  }
47
65
  }
48
66
  toast.error(error instanceof Error ? error.message : 'Failed to update profile');
@@ -60,9 +78,13 @@ export const useUpdateProfile = () => {
60
78
  if (updates.avatar && activeSessionId && oxyServices) {
61
79
  refreshAvatarInStore(activeSessionId, updates.avatar, oxyServices);
62
80
  }
63
- // Invalidate all related queries to refresh everywhere
81
+ // Invalidate all related queries so every consumer (AccountSwitcher,
82
+ // session lists, managed accounts, etc.) refetches the fresh profile.
83
+ // Critical right after `username` is set the first time, when every
84
+ // cached "session profile" still reports the user as unnamed.
64
85
  invalidateUserQueries(queryClient);
65
86
  invalidateAccountQueries(queryClient);
87
+ invalidateSessionQueries(queryClient);
66
88
  },
67
89
  });
68
90
  };
@@ -102,11 +124,25 @@ export const useUploadAvatar = () => {
102
124
  }
103
125
  return { previousUser };
104
126
  },
105
- onError: (error, file, context) => {
127
+ onError: (error, _file, context) => {
128
+ // Avatar upload only mutates the `avatar` field — restore only that key
106
129
  if (context?.previousUser) {
107
- queryClient.setQueryData(queryKeys.accounts.current(), context.previousUser);
130
+ const previousAvatar = context.previousUser.avatar;
131
+ const current = queryClient.getQueryData(queryKeys.accounts.current());
132
+ if (current) {
133
+ queryClient.setQueryData(queryKeys.accounts.current(), {
134
+ ...current,
135
+ avatar: previousAvatar,
136
+ });
137
+ }
108
138
  if (activeSessionId) {
109
- queryClient.setQueryData(queryKeys.users.profile(activeSessionId), context.previousUser);
139
+ const currentProfile = queryClient.getQueryData(queryKeys.users.profile(activeSessionId));
140
+ if (currentProfile) {
141
+ queryClient.setQueryData(queryKeys.users.profile(activeSessionId), {
142
+ ...currentProfile,
143
+ avatar: previousAvatar,
144
+ });
145
+ }
110
146
  }
111
147
  }
112
148
  toast.error(error instanceof Error ? error.message : 'Failed to upload avatar');
@@ -122,24 +158,46 @@ export const useUploadAvatar = () => {
122
158
  if (data?.avatar && activeSessionId && oxyServices) {
123
159
  refreshAvatarInStore(activeSessionId, data.avatar, oxyServices);
124
160
  }
125
- // Invalidate all related queries to refresh everywhere
161
+ // Invalidate all related queries to refresh everywhere, including the
162
+ // sessions cache so other-account avatars update too.
126
163
  invalidateUserQueries(queryClient);
127
164
  invalidateAccountQueries(queryClient);
165
+ invalidateSessionQueries(queryClient);
128
166
  toast.success('Avatar updated successfully');
129
167
  },
130
168
  });
131
169
  };
132
170
  /**
133
- * Update account settings
171
+ * Update account settings (privacy preferences).
172
+ *
173
+ * Privacy settings are not part of the `PUT /users/me` allow-list; the API
174
+ * would silently drop them. Route through `updatePrivacySettings` so the
175
+ * dedicated `PATCH /privacy/:id/privacy` endpoint performs a dot-path merge
176
+ * and returns the updated `privacySettings` object.
177
+ *
178
+ * The returned object exposes the standard mutation surface PLUS a
179
+ * convenience `mutate(updates)` / `mutateAsync(updates)` that snapshots
180
+ * the current user from `useWebOxy()` at dispatch time.
134
181
  */
135
182
  export const useUpdateAccountSettings = () => {
136
- const { oxyServices, activeSessionId } = useWebOxy();
183
+ const { oxyServices, activeSessionId, user } = useWebOxy();
137
184
  const queryClient = useQueryClient();
138
- return useMutation({
139
- mutationFn: async (settings) => {
140
- return await oxyServices.updateProfile({ privacySettings: settings });
185
+ const mutation = useMutation({
186
+ mutationFn: async ({ updates, currentUser }) => {
187
+ const userId = currentUser.id;
188
+ if (!userId) {
189
+ throw new Error('User ID is required to update account settings');
190
+ }
191
+ const updatedPrivacy = await authenticatedApiCall(oxyServices, activeSessionId, () => oxyServices.updatePrivacySettings(updates, userId));
192
+ // Rebuild against the dispatch-time snapshot, NOT the live cache.
193
+ // The cache may have been mutated by a sibling write between
194
+ // dispatch and settle.
195
+ return {
196
+ ...currentUser,
197
+ privacySettings: updatedPrivacy,
198
+ };
141
199
  },
142
- onMutate: async (settings) => {
200
+ onMutate: async ({ updates }) => {
143
201
  await queryClient.cancelQueries({ queryKey: queryKeys.accounts.settings() });
144
202
  const previousUser = queryClient.getQueryData(queryKeys.accounts.current());
145
203
  if (previousUser) {
@@ -147,15 +205,31 @@ export const useUpdateAccountSettings = () => {
147
205
  ...previousUser,
148
206
  privacySettings: {
149
207
  ...previousUser.privacySettings,
150
- ...settings,
208
+ ...updates,
151
209
  },
152
210
  });
153
211
  }
154
212
  return { previousUser };
155
213
  },
156
- onError: (error, settings, context) => {
157
- if (context?.previousUser) {
158
- queryClient.setQueryData(queryKeys.accounts.current(), context.previousUser);
214
+ onError: (error, { updates }, context) => {
215
+ // Restore only the privacySettings keys this mutation tried to change
216
+ if (context?.previousUser && updates) {
217
+ const previousPrivacy = context.previousUser.privacySettings ?? {};
218
+ const changedKeys = Object.keys(updates);
219
+ const partialPrivacyRollback = changedKeys.reduce((acc, key) => {
220
+ acc[key] = previousPrivacy[key];
221
+ return acc;
222
+ }, {});
223
+ const current = queryClient.getQueryData(queryKeys.accounts.current());
224
+ if (current) {
225
+ queryClient.setQueryData(queryKeys.accounts.current(), {
226
+ ...current,
227
+ privacySettings: {
228
+ ...(current.privacySettings ?? {}),
229
+ ...partialPrivacyRollback,
230
+ },
231
+ });
232
+ }
159
233
  }
160
234
  toast.error(error instanceof Error ? error.message : 'Failed to update settings');
161
235
  },
@@ -170,6 +244,26 @@ export const useUpdateAccountSettings = () => {
170
244
  queryClient.invalidateQueries({ queryKey: queryKeys.accounts.settings() });
171
245
  },
172
246
  });
247
+ // Wrap mutate/mutateAsync so call sites pass a plain settings object and
248
+ // the current user is captured at dispatch time.
249
+ return {
250
+ ...mutation,
251
+ mutate: (updates) => {
252
+ const currentUser = user ?? queryClient.getQueryData(queryKeys.accounts.current());
253
+ if (!currentUser) {
254
+ toast.error('Cannot update account settings: no current user');
255
+ return;
256
+ }
257
+ mutation.mutate({ updates, currentUser });
258
+ },
259
+ mutateAsync: async (updates) => {
260
+ const currentUser = user ?? queryClient.getQueryData(queryKeys.accounts.current());
261
+ if (!currentUser) {
262
+ throw new Error('Cannot update account settings: no current user');
263
+ }
264
+ return mutation.mutateAsync({ updates, currentUser });
265
+ },
266
+ };
173
267
  };
174
268
  /**
175
269
  * Update privacy settings with optimistic updates and authentication handling
@@ -215,43 +309,91 @@ export const useUpdatePrivacySettings = () => {
215
309
  }
216
310
  return { previousPrivacySettings, previousUser };
217
311
  },
218
- // On error, rollback
219
- onError: (error, { userId }, context) => {
312
+ // On error, rollback ONLY the privacy keys this mutation tried to change.
313
+ // Restoring the entire previous object would wipe out other concurrent
314
+ // optimistic updates (e.g. user toggles two privacy switches in quick
315
+ // succession; failure on one must not revert the other).
316
+ onError: (error, { settings, userId }, context) => {
220
317
  const targetUserId = userId || user?.id;
221
- if (context?.previousPrivacySettings && targetUserId) {
222
- queryClient.setQueryData(queryKeys.privacy.settings(targetUserId), context.previousPrivacySettings);
318
+ const changedKeys = settings ? Object.keys(settings) : [];
319
+ // Rollback the privacy.settings query (partial)
320
+ if (context?.previousPrivacySettings && targetUserId && changedKeys.length > 0) {
321
+ const previousPrivacy = context.previousPrivacySettings;
322
+ const partialPrivacyRollback = changedKeys.reduce((acc, key) => {
323
+ acc[key] = previousPrivacy[key];
324
+ return acc;
325
+ }, {});
326
+ const currentPrivacy = queryClient.getQueryData(queryKeys.privacy.settings(targetUserId));
327
+ if (currentPrivacy) {
328
+ queryClient.setQueryData(queryKeys.privacy.settings(targetUserId), {
329
+ ...currentPrivacy,
330
+ ...partialPrivacyRollback,
331
+ });
332
+ }
223
333
  }
224
- if (context?.previousUser) {
225
- queryClient.setQueryData(queryKeys.accounts.current(), context.previousUser);
334
+ // Rollback the accounts.current() user.privacySettings (partial)
335
+ if (context?.previousUser && changedKeys.length > 0) {
336
+ const previousPrivacy = (context.previousUser.privacySettings ?? {});
337
+ const partialPrivacyRollback = changedKeys.reduce((acc, key) => {
338
+ acc[key] = previousPrivacy[key];
339
+ return acc;
340
+ }, {});
341
+ const current = queryClient.getQueryData(queryKeys.accounts.current());
342
+ if (current) {
343
+ queryClient.setQueryData(queryKeys.accounts.current(), {
344
+ ...current,
345
+ privacySettings: {
346
+ ...(current.privacySettings ?? {}),
347
+ ...partialPrivacyRollback,
348
+ },
349
+ });
350
+ }
351
+ }
352
+ // After partial rollback, reconcile against the server so the cache
353
+ // converges to the authoritative state for the failed keys.
354
+ if (targetUserId) {
355
+ queryClient.invalidateQueries({ queryKey: queryKeys.privacy.settings(targetUserId) });
226
356
  }
227
357
  toast.error(error instanceof Error ? error.message : 'Failed to update privacy settings');
228
358
  },
229
- // On success, invalidate and refetch
230
- onSuccess: (data, { userId }) => {
359
+ // On success, MERGE the server response into the cached state. Older
360
+ // API builds returned only the changed field (or wiped the privacySettings
361
+ // subdocument when handed a partial update), which would clobber every
362
+ // other toggle if we blindly replaced. Defensive merge means the UI stays
363
+ // consistent regardless of server behaviour.
364
+ //
365
+ // BOTH the privacy.settings query AND the accounts.current() user are
366
+ // gated on `targetUserId`. If it's missing (no userId param, no logged-in
367
+ // user) the optimistic update in onMutate would have early-returned too,
368
+ // so neither cache was ever touched — there's nothing to reconcile here.
369
+ onSuccess: (data, { userId, settings }) => {
231
370
  const targetUserId = userId || user?.id;
232
- if (targetUserId) {
233
- queryClient.setQueryData(queryKeys.privacy.settings(targetUserId), data);
234
- }
235
- // Also update account query if it contains privacy settings
371
+ if (!targetUserId)
372
+ return;
373
+ const incoming = (data ?? {});
374
+ const requested = (settings ?? {});
375
+ queryClient.setQueryData(queryKeys.privacy.settings(targetUserId), (previous) => ({
376
+ ...(previous ?? {}),
377
+ ...requested,
378
+ ...incoming, // server wins for fields it explicitly returned
379
+ }));
236
380
  const currentUser = queryClient.getQueryData(queryKeys.accounts.current());
237
381
  if (currentUser) {
238
382
  const updatedUser = {
239
383
  ...currentUser,
240
- privacySettings: data,
384
+ privacySettings: {
385
+ ...(currentUser.privacySettings ?? {}),
386
+ ...requested,
387
+ ...incoming,
388
+ },
241
389
  };
242
390
  queryClient.setQueryData(queryKeys.accounts.current(), updatedUser);
243
- // Update authStore so frontend components see the changes immediately
244
391
  useAuthStore.getState().setUser(updatedUser);
245
392
  }
246
- invalidateAccountQueries(queryClient);
247
- },
248
- // Always refetch after error or success
249
- onSettled: (data, error, { userId }) => {
250
- const targetUserId = userId || user?.id;
251
- if (targetUserId) {
252
- queryClient.invalidateQueries({ queryKey: queryKeys.privacy.settings(targetUserId) });
253
- }
254
- queryClient.invalidateQueries({ queryKey: queryKeys.accounts.current() });
393
+ // Deliberately NOT invalidating any queries here. invalidateAccountQueries
394
+ // invalidates accounts.all which is the prefix for accounts.current(),
395
+ // triggering a background refetch of useCurrentUser that would overwrite
396
+ // the merged state above. The onSuccess merge is the source of truth.
255
397
  },
256
398
  });
257
399
  };
@@ -1,104 +1,147 @@
1
+ /**
2
+ * Web QueryClient with offline-first defaults + localStorage persistence.
3
+ *
4
+ * Mirrors the persistence behaviour in `@oxyhq/services/queryClient` so
5
+ * web auth apps (FedCM, popup, redirect flows) survive a page reload with
6
+ * cached identity + paused mutations intact.
7
+ *
8
+ * Persistence is opt-in via `attachQueryPersistence(...)` so SSR callers
9
+ * (Next.js getServerSideProps, Vite SSR, tests) can create a stateless
10
+ * client without touching `window`.
11
+ */
1
12
  import { QueryClient } from '@tanstack/react-query';
2
- const QUERY_CACHE_KEY = 'oxy_query_cache';
3
- const QUERY_CACHE_VERSION = '1';
13
+ import { persistQueryClient, } from '@tanstack/react-query-persist-client';
14
+ import { createSyncStoragePersister } from '@tanstack/query-sync-storage-persister';
15
+ const QUERY_CACHE_KEY = 'oxy_auth_query_cache_v2';
16
+ const QUERY_CACHE_MAX_AGE = 30 * 24 * 60 * 60 * 1000;
17
+ const QUERY_PERSIST_THROTTLE_MS = 1000;
4
18
  /**
5
- * Custom persistence adapter for TanStack Query using our StorageInterface
19
+ * Query-key prefixes whose data is safe to restore across reloads.
20
+ * Web auth surfaces are session/profile heavy — lists and history are not
21
+ * persisted to keep the localStorage footprint small.
6
22
  */
7
- export const createPersistenceAdapter = (storage) => {
8
- return {
9
- persistClient: async (client) => {
10
- try {
11
- const serialized = JSON.stringify({
12
- clientState: client,
13
- timestamp: Date.now(),
14
- version: QUERY_CACHE_VERSION,
15
- });
16
- await storage.setItem(QUERY_CACHE_KEY, serialized);
17
- }
18
- catch (error) {
19
- if (process.env.NODE_ENV !== 'production') {
20
- console.warn('[QueryClient] Failed to persist cache:', error);
21
- }
23
+ const PERSISTED_QUERY_PREFIXES = [
24
+ 'accounts',
25
+ 'users',
26
+ 'sessions',
27
+ 'auth',
28
+ ];
29
+ function shouldDehydrateQuery(query) {
30
+ if (query.state.status !== 'success')
31
+ return false;
32
+ const head = query.queryKey[0];
33
+ return typeof head === 'string' && PERSISTED_QUERY_PREFIXES.includes(head);
34
+ }
35
+ function shouldDehydrateMutation(_mutation) {
36
+ return true;
37
+ }
38
+ /**
39
+ * Best-effort detection — works in browsers, Node SSR, and React Server
40
+ * Components. `localStorage` is gated behind `window` because Node and edge
41
+ * runtimes may polyfill `globalThis.localStorage` inconsistently.
42
+ */
43
+ function getBrowserLocalStorage() {
44
+ if (typeof window === 'undefined')
45
+ return null;
46
+ try {
47
+ if (!window.localStorage)
48
+ return null;
49
+ return window.localStorage;
50
+ }
51
+ catch {
52
+ // Access blocked (Safari Private Mode, sandboxed iframe, etc.)
53
+ return null;
54
+ }
55
+ }
56
+ export const createPersistenceAdapter = (storage) => ({
57
+ persistClient: async (client) => {
58
+ try {
59
+ await storage.setItem(QUERY_CACHE_KEY, JSON.stringify(client));
60
+ }
61
+ catch (error) {
62
+ if (process.env.NODE_ENV !== 'production') {
63
+ console.warn('[QueryClient] Failed to persist cache', error);
22
64
  }
23
- },
24
- restoreClient: async () => {
25
- try {
26
- const cached = await storage.getItem(QUERY_CACHE_KEY);
27
- if (!cached)
28
- return undefined;
29
- const parsed = JSON.parse(cached);
30
- // Check version compatibility
31
- if (parsed.version !== QUERY_CACHE_VERSION) {
32
- // Clear old cache on version mismatch
33
- await storage.removeItem(QUERY_CACHE_KEY);
34
- return undefined;
35
- }
36
- // Check if cache is too old (30 days)
37
- const maxAge = 30 * 24 * 60 * 60 * 1000;
38
- if (parsed.timestamp && Date.now() - parsed.timestamp > maxAge) {
39
- await storage.removeItem(QUERY_CACHE_KEY);
40
- return undefined;
41
- }
42
- return parsed.clientState;
65
+ }
66
+ },
67
+ restoreClient: async () => {
68
+ try {
69
+ const cached = await storage.getItem(QUERY_CACHE_KEY);
70
+ return cached ? JSON.parse(cached) : undefined;
71
+ }
72
+ catch (error) {
73
+ if (process.env.NODE_ENV !== 'production') {
74
+ console.warn('[QueryClient] Failed to restore cache', error);
43
75
  }
44
- catch (error) {
45
- if (process.env.NODE_ENV !== 'production') {
46
- console.warn('[QueryClient] Failed to restore cache:', error);
47
- }
48
- return undefined;
76
+ return undefined;
77
+ }
78
+ },
79
+ removeClient: async () => {
80
+ try {
81
+ await storage.removeItem(QUERY_CACHE_KEY);
82
+ }
83
+ catch (error) {
84
+ if (process.env.NODE_ENV !== 'production') {
85
+ console.warn('[QueryClient] Failed to remove cache', error);
49
86
  }
87
+ }
88
+ },
89
+ });
90
+ export const createQueryClient = () => new QueryClient({
91
+ defaultOptions: {
92
+ queries: {
93
+ staleTime: 5 * 60 * 1000,
94
+ gcTime: 30 * 60 * 1000,
95
+ retry: 3,
96
+ retryDelay: (attempt) => Math.min(1000 * 2 ** attempt, 30000),
97
+ refetchOnReconnect: true,
98
+ refetchOnWindowFocus: false,
99
+ networkMode: 'offlineFirst',
50
100
  },
51
- removeClient: async () => {
52
- try {
53
- await storage.removeItem(QUERY_CACHE_KEY);
54
- }
55
- catch (error) {
56
- if (process.env.NODE_ENV !== 'production') {
57
- console.warn('[QueryClient] Failed to remove cache:', error);
58
- }
59
- }
101
+ mutations: {
102
+ retry: 1,
103
+ networkMode: 'offlineFirst',
60
104
  },
61
- };
62
- };
105
+ },
106
+ });
63
107
  /**
64
- * Create a QueryClient with offline-first configuration
108
+ * Wire `persistQueryClient` to browser `localStorage` (or a no-op when not
109
+ * in a browser). Returns the restore promise so consumers can `await` it
110
+ * before exposing the client to <Suspense> boundaries.
65
111
  */
66
- export const createQueryClient = (storage) => {
67
- const client = new QueryClient({
68
- defaultOptions: {
69
- queries: {
70
- // Data is fresh for 5 minutes
71
- staleTime: 5 * 60 * 1000,
72
- // Keep unused data in cache for 10 minutes
73
- gcTime: 10 * 60 * 1000,
74
- // Retry 3 times with exponential backoff
75
- retry: 3,
76
- retryDelay: (attemptIndex) => Math.min(1000 * 2 ** attemptIndex, 30000),
77
- // Refetch on reconnect
78
- refetchOnReconnect: true,
79
- // Don't refetch on window focus (better for mobile)
80
- refetchOnWindowFocus: false,
81
- // Offline-first: use cache when offline
82
- networkMode: 'offlineFirst',
83
- },
84
- mutations: {
85
- // Retry once for mutations
86
- retry: 1,
87
- // Offline-first: queue mutations when offline
88
- networkMode: 'offlineFirst',
89
- },
112
+ export const attachQueryPersistence = (queryClient) => {
113
+ const localStorage = getBrowserLocalStorage();
114
+ if (!localStorage) {
115
+ return { restored: Promise.resolve(), unsubscribe: () => { } };
116
+ }
117
+ const persister = createSyncStoragePersister({
118
+ storage: localStorage,
119
+ key: QUERY_CACHE_KEY,
120
+ throttleTime: QUERY_PERSIST_THROTTLE_MS,
121
+ });
122
+ const [unsubscribe, restored] = persistQueryClient({
123
+ queryClient,
124
+ persister,
125
+ maxAge: QUERY_CACHE_MAX_AGE,
126
+ dehydrateOptions: {
127
+ shouldDehydrateQuery,
128
+ shouldDehydrateMutation,
90
129
  },
91
130
  });
92
- // Note: Persistence is handled by TanStack Query's built-in persistence
93
- // For now, we rely on the query client's default behavior with networkMode: 'offlineFirst'
94
- // The cache will be available in memory and queries will use cached data when offline
95
- // Full persistence to AsyncStorage can be added later with @tanstack/react-query-persist-client if needed
96
- return client;
131
+ restored.catch((error) => {
132
+ if (process.env.NODE_ENV !== 'production') {
133
+ console.warn('[QueryClient] Failed to restore persisted cache', error);
134
+ }
135
+ });
136
+ return { unsubscribe, restored };
97
137
  };
98
- /**
99
- * Clear persisted query cache
100
- */
101
138
  export const clearQueryCache = async (storage) => {
102
- const adapter = createPersistenceAdapter(storage);
103
- await adapter.removeClient();
139
+ try {
140
+ await storage.removeItem(QUERY_CACHE_KEY);
141
+ }
142
+ catch (error) {
143
+ if (process.env.NODE_ENV !== 'production') {
144
+ console.warn('[QueryClient] Failed to remove cache', error);
145
+ }
146
+ }
104
147
  };