@oxy.so/services 13.11.0 → 13.12.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 (118) hide show
  1. package/lib/commonjs/ui/context/commitSessionFlow.js +4 -0
  2. package/lib/commonjs/ui/context/commitSessionFlow.js.map +1 -1
  3. package/lib/commonjs/ui/context/hooks/useAuthOperations.js +17 -2
  4. package/lib/commonjs/ui/context/hooks/useAuthOperations.js.map +1 -1
  5. package/lib/commonjs/ui/context/oxyContextHelpers.js +2 -0
  6. package/lib/commonjs/ui/context/oxyContextHelpers.js.map +1 -1
  7. package/lib/commonjs/ui/hooks/accountQueryPersistence.js +19 -8
  8. package/lib/commonjs/ui/hooks/accountQueryPersistence.js.map +1 -1
  9. package/lib/commonjs/ui/hooks/persistedQueryCache.js +3 -3
  10. package/lib/commonjs/ui/hooks/persistedQueryCache.js.map +1 -1
  11. package/lib/commonjs/ui/hooks/queryClient.js +5 -1
  12. package/lib/commonjs/ui/hooks/queryClient.js.map +1 -1
  13. package/lib/commonjs/ui/hooks/useFollowTarget.js +9 -1
  14. package/lib/commonjs/ui/hooks/useFollowTarget.js.map +1 -1
  15. package/lib/commonjs/ui/hooks/useSessionManagement.js +3 -0
  16. package/lib/commonjs/ui/hooks/useSessionManagement.js.map +1 -1
  17. package/lib/commonjs/ui/runtime/createOxyRuntime.js +1 -0
  18. package/lib/commonjs/ui/runtime/createOxyRuntime.js.map +1 -1
  19. package/lib/commonjs/ui/stores/followStore.js +14 -0
  20. package/lib/commonjs/ui/stores/followStore.js.map +1 -1
  21. package/lib/commonjs/ui/stores/followTargetStore.js +15 -5
  22. package/lib/commonjs/ui/stores/followTargetStore.js.map +1 -1
  23. package/lib/commonjs/ui/utils/errorHandlers.js +2 -0
  24. package/lib/commonjs/ui/utils/errorHandlers.js.map +1 -1
  25. package/lib/commonjs/ui/utils/followErrors.js +2 -0
  26. package/lib/commonjs/ui/utils/followErrors.js.map +1 -1
  27. package/lib/commonjs/ui/utils/sessionChangedError.js +20 -0
  28. package/lib/commonjs/ui/utils/sessionChangedError.js.map +1 -0
  29. package/lib/commonjs/ui/utils/sessionHelpers.js +2 -0
  30. package/lib/commonjs/ui/utils/sessionHelpers.js.map +1 -1
  31. package/lib/module/ui/context/commitSessionFlow.js +4 -0
  32. package/lib/module/ui/context/commitSessionFlow.js.map +1 -1
  33. package/lib/module/ui/context/hooks/useAuthOperations.js +17 -2
  34. package/lib/module/ui/context/hooks/useAuthOperations.js.map +1 -1
  35. package/lib/module/ui/context/oxyContextHelpers.js +3 -0
  36. package/lib/module/ui/context/oxyContextHelpers.js.map +1 -1
  37. package/lib/module/ui/hooks/accountQueryPersistence.js +19 -8
  38. package/lib/module/ui/hooks/accountQueryPersistence.js.map +1 -1
  39. package/lib/module/ui/hooks/persistedQueryCache.js +3 -3
  40. package/lib/module/ui/hooks/persistedQueryCache.js.map +1 -1
  41. package/lib/module/ui/hooks/queryClient.js +5 -1
  42. package/lib/module/ui/hooks/queryClient.js.map +1 -1
  43. package/lib/module/ui/hooks/useFollowTarget.js +10 -2
  44. package/lib/module/ui/hooks/useFollowTarget.js.map +1 -1
  45. package/lib/module/ui/hooks/useSessionManagement.js +3 -0
  46. package/lib/module/ui/hooks/useSessionManagement.js.map +1 -1
  47. package/lib/module/ui/runtime/createOxyRuntime.js +1 -0
  48. package/lib/module/ui/runtime/createOxyRuntime.js.map +1 -1
  49. package/lib/module/ui/stores/followStore.js +14 -0
  50. package/lib/module/ui/stores/followStore.js.map +1 -1
  51. package/lib/module/ui/stores/followTargetStore.js +14 -5
  52. package/lib/module/ui/stores/followTargetStore.js.map +1 -1
  53. package/lib/module/ui/utils/errorHandlers.js +2 -0
  54. package/lib/module/ui/utils/errorHandlers.js.map +1 -1
  55. package/lib/module/ui/utils/followErrors.js +3 -0
  56. package/lib/module/ui/utils/followErrors.js.map +1 -1
  57. package/lib/module/ui/utils/sessionChangedError.js +15 -0
  58. package/lib/module/ui/utils/sessionChangedError.js.map +1 -0
  59. package/lib/module/ui/utils/sessionHelpers.js +3 -0
  60. package/lib/module/ui/utils/sessionHelpers.js.map +1 -1
  61. package/lib/typescript/commonjs/ui/context/commitSessionFlow.d.ts.map +1 -1
  62. package/lib/typescript/commonjs/ui/context/hooks/useAuthOperations.d.ts.map +1 -1
  63. package/lib/typescript/commonjs/ui/context/oxyContextHelpers.d.ts.map +1 -1
  64. package/lib/typescript/commonjs/ui/hooks/accountQueryPersistence.d.ts +30 -3
  65. package/lib/typescript/commonjs/ui/hooks/accountQueryPersistence.d.ts.map +1 -1
  66. package/lib/typescript/commonjs/ui/hooks/persistedQueryCache.d.ts +7 -3
  67. package/lib/typescript/commonjs/ui/hooks/persistedQueryCache.d.ts.map +1 -1
  68. package/lib/typescript/commonjs/ui/hooks/queryClient.d.ts.map +1 -1
  69. package/lib/typescript/commonjs/ui/hooks/useFollowTarget.d.ts.map +1 -1
  70. package/lib/typescript/commonjs/ui/hooks/useSessionManagement.d.ts.map +1 -1
  71. package/lib/typescript/commonjs/ui/icons/MaterialCommunityIcons.d.ts +1 -1
  72. package/lib/typescript/commonjs/ui/runtime/createOxyRuntime.d.ts.map +1 -1
  73. package/lib/typescript/commonjs/ui/stores/followStore.d.ts.map +1 -1
  74. package/lib/typescript/commonjs/ui/stores/followTargetStore.d.ts +2 -0
  75. package/lib/typescript/commonjs/ui/stores/followTargetStore.d.ts.map +1 -1
  76. package/lib/typescript/commonjs/ui/utils/errorHandlers.d.ts.map +1 -1
  77. package/lib/typescript/commonjs/ui/utils/followErrors.d.ts.map +1 -1
  78. package/lib/typescript/commonjs/ui/utils/sessionChangedError.d.ts +8 -0
  79. package/lib/typescript/commonjs/ui/utils/sessionChangedError.d.ts.map +1 -0
  80. package/lib/typescript/commonjs/ui/utils/sessionHelpers.d.ts.map +1 -1
  81. package/lib/typescript/module/ui/context/commitSessionFlow.d.ts.map +1 -1
  82. package/lib/typescript/module/ui/context/hooks/useAuthOperations.d.ts.map +1 -1
  83. package/lib/typescript/module/ui/context/oxyContextHelpers.d.ts.map +1 -1
  84. package/lib/typescript/module/ui/hooks/accountQueryPersistence.d.ts +30 -3
  85. package/lib/typescript/module/ui/hooks/accountQueryPersistence.d.ts.map +1 -1
  86. package/lib/typescript/module/ui/hooks/persistedQueryCache.d.ts +7 -3
  87. package/lib/typescript/module/ui/hooks/persistedQueryCache.d.ts.map +1 -1
  88. package/lib/typescript/module/ui/hooks/queryClient.d.ts.map +1 -1
  89. package/lib/typescript/module/ui/hooks/useFollowTarget.d.ts.map +1 -1
  90. package/lib/typescript/module/ui/hooks/useSessionManagement.d.ts.map +1 -1
  91. package/lib/typescript/module/ui/icons/MaterialCommunityIcons.d.ts +1 -1
  92. package/lib/typescript/module/ui/runtime/createOxyRuntime.d.ts.map +1 -1
  93. package/lib/typescript/module/ui/stores/followStore.d.ts.map +1 -1
  94. package/lib/typescript/module/ui/stores/followTargetStore.d.ts +2 -0
  95. package/lib/typescript/module/ui/stores/followTargetStore.d.ts.map +1 -1
  96. package/lib/typescript/module/ui/utils/errorHandlers.d.ts.map +1 -1
  97. package/lib/typescript/module/ui/utils/followErrors.d.ts.map +1 -1
  98. package/lib/typescript/module/ui/utils/sessionChangedError.d.ts +8 -0
  99. package/lib/typescript/module/ui/utils/sessionChangedError.d.ts.map +1 -0
  100. package/lib/typescript/module/ui/utils/sessionHelpers.d.ts.map +1 -1
  101. package/package.json +2 -2
  102. package/src/ui/context/__tests__/commitSessionFlow.test.ts +36 -0
  103. package/src/ui/context/commitSessionFlow.ts +4 -0
  104. package/src/ui/context/hooks/useAuthOperations.ts +14 -2
  105. package/src/ui/context/oxyContextHelpers.ts +3 -0
  106. package/src/ui/hooks/accountQueryPersistence.ts +60 -13
  107. package/src/ui/hooks/persistedQueryCache.ts +17 -3
  108. package/src/ui/hooks/queryClient.ts +8 -1
  109. package/src/ui/hooks/useFollowTarget.ts +10 -1
  110. package/src/ui/hooks/useSessionManagement.ts +3 -0
  111. package/src/ui/runtime/createOxyRuntime.ts +1 -0
  112. package/src/ui/stores/__tests__/followStore.test.ts +62 -0
  113. package/src/ui/stores/followStore.ts +14 -0
  114. package/src/ui/stores/followTargetStore.ts +11 -1
  115. package/src/ui/utils/errorHandlers.ts +2 -0
  116. package/src/ui/utils/followErrors.ts +3 -0
  117. package/src/ui/utils/sessionChangedError.ts +14 -0
  118. package/src/ui/utils/sessionHelpers.ts +2 -0
@@ -54,7 +54,9 @@ export interface AccountQueryMeta {
54
54
  persist: 'account';
55
55
  }
56
56
 
57
- function isAccountQuery(query: Query): boolean {
57
+ type QueryIdentity = { queryKey: QueryKey; meta?: Query['meta'] };
58
+
59
+ function isAccountQuery(query: Pick<QueryIdentity, 'meta'>): boolean {
58
60
  return (query.meta as Partial<AccountQueryMeta> | undefined)?.persist === 'account';
59
61
  }
60
62
 
@@ -75,22 +77,44 @@ export interface AccountQueriesConfig {
75
77
  /**
76
78
  * Roots that belong to the account too, but must not be written to disk
77
79
  * (signed URLs, AI output, search results): dropped on a switch, never persisted.
80
+ * `'all'` keeps every account-owned query in memory only, including marked
81
+ * queries unless `persistMarkedQueries` is enabled. Device-level SDK roots
82
+ * remain excluded unless an individual query declares account persistence.
83
+ */
84
+ memoryOnlyRoots?: readonly string[] | 'all';
85
+ /**
86
+ * When `memoryOnlyRoots` is `'all'`, persist queries explicitly marked with
87
+ * `meta.persist = 'account'`. SDK memory-only roots remain excluded.
88
+ * Defaults to false.
78
89
  */
79
- memoryOnlyRoots?: readonly string[];
90
+ persistMarkedQueries?: boolean;
80
91
  /**
81
92
  * Mutation keys whose PAUSED (offline) instances replay after a restart for
82
93
  * the same account. Register their `mutationFn` with `setMutationDefaults`.
83
94
  */
84
95
  mutationKeys?: readonly QueryKey[];
96
+ /**
97
+ * Remove unregistered mutation records as well on every account transition.
98
+ * Only registered paused mutations are saved and restored; unregistered
99
+ * mutations are excluded from the shared persisted cache too. Removing a record
100
+ * does not cancel an in-flight request or its callbacks; apps must scope
101
+ * their mutation effects to the account that started them.
102
+ * Defaults to false.
103
+ */
104
+ clearUnregisteredMutations?: boolean;
85
105
  }
86
106
 
87
107
  /** Which queries and mutations an `AccountQueriesConfig` makes the account's. */
88
108
  export interface AccountQueryOwnership {
89
109
  /** Dropped from memory on a subject change. */
90
- ownsQuery: (query: Query) => boolean;
110
+ ownsQuery: (query: QueryIdentity) => boolean;
91
111
  /** Written to the account's blob. */
92
- persistsQuery: (query: Query) => boolean;
112
+ persistsQuery: (query: QueryIdentity) => boolean;
93
113
  ownsMutation: (mutation: Mutation) => boolean;
114
+ /** Eligible for the account's persisted paused mutations. */
115
+ persistsMutation: (mutation: Mutation) => boolean;
116
+ /** Whether a stored mutation key belongs to this account's offline queue. */
117
+ persistsMutationKey: (key: QueryKey | undefined) => boolean;
94
118
  }
95
119
 
96
120
  /** SDK roots that hold device-level data, the same whoever is signed in. */
@@ -102,14 +126,23 @@ const NO_ROOTS: AccountQueriesConfig = { roots: [] };
102
126
  export function accountQueryOwnership(
103
127
  config: AccountQueriesConfig = NO_ROOTS,
104
128
  ): AccountQueryOwnership {
105
- const memoryOnly = new Set([...(config.memoryOnlyRoots ?? []), ...SDK_MEMORY_ONLY_ROOTS]);
129
+ const memoryOnlyAll = config.memoryOnlyRoots === 'all';
130
+ const memoryOnly = new Set([
131
+ ...(memoryOnlyAll ? [] : (config.memoryOnlyRoots ?? [])),
132
+ ...SDK_MEMORY_ONLY_ROOTS,
133
+ ]);
106
134
  const sdkRoots = new Set(PERSISTED_QUERY_PREFIXES);
107
135
  const declared = config.roots === 'all' ? null : new Set(config.roots);
108
136
  const mutationHashes = new Set((config.mutationKeys ?? []).map((key) => hashKey(key)));
137
+ const persistsMutationKey = (key: QueryKey | undefined): boolean =>
138
+ key !== undefined && mutationHashes.has(hashKey(key));
139
+ const isRegisteredMutation = (mutation: Mutation): boolean =>
140
+ persistsMutationKey(mutation.options.mutationKey);
109
141
 
110
- const ownsQuery = (query: Query): boolean => {
142
+ const ownsQuery = (query: QueryIdentity): boolean => {
111
143
  if (isAccountQuery(query)) return true;
112
144
  const root = rootOf(query);
145
+ if (memoryOnlyAll && (root === null || !DEVICE_ROOTS.has(root))) return true;
113
146
  if (root !== null && memoryOnly.has(root)) return true;
114
147
  if (declared === null) return root === null || !DEVICE_ROOTS.has(root);
115
148
  return root !== null && declared.has(root);
@@ -119,16 +152,23 @@ export function accountQueryOwnership(
119
152
  persistsQuery: (query) => {
120
153
  if (!ownsQuery(query)) return false;
121
154
  const root = rootOf(query);
155
+ if (memoryOnlyAll) {
156
+ return (
157
+ config.persistMarkedQueries === true &&
158
+ isAccountQuery(query) &&
159
+ (root === null || !memoryOnly.has(root))
160
+ );
161
+ }
122
162
  if (root === null) return true;
123
163
  // The SDK's own roots stay with the SDK persister.
124
164
  return (
125
165
  !memoryOnly.has(root) && (isAccountQuery(query) || declared !== null || !sdkRoots.has(root))
126
166
  );
127
167
  },
128
- ownsMutation: (mutation) => {
129
- const key = mutation.options.mutationKey;
130
- return key !== undefined && mutationHashes.has(hashKey(key));
131
- },
168
+ ownsMutation: (mutation) =>
169
+ config.clearUnregisteredMutations === true || isRegisteredMutation(mutation),
170
+ persistsMutation: isRegisteredMutation,
171
+ persistsMutationKey,
132
172
  };
133
173
  }
134
174
 
@@ -160,10 +200,11 @@ export function createAccountQueryPersistence(
160
200
  queryClient: QueryClient,
161
201
  config?: AccountQueriesConfig,
162
202
  ): AccountQueryPersistence {
163
- const { ownsQuery, persistsQuery, ownsMutation } = accountQueryOwnership(config);
203
+ const { ownsQuery, persistsQuery, ownsMutation, persistsMutation, persistsMutationKey } =
204
+ accountQueryOwnership(config);
164
205
  const filters: PersistFilters = {
165
206
  persistsQuery,
166
- shouldDehydrateMutation: (mutation) => mutation.state.isPaused && ownsMutation(mutation),
207
+ shouldDehydrateMutation: (mutation) => mutation.state.isPaused && persistsMutation(mutation),
167
208
  };
168
209
  const listeners = new Set<() => void>();
169
210
 
@@ -221,7 +262,13 @@ export function createAccountQueryPersistence(
221
262
  const raw = await storage.getItem(key).catch(() => null);
222
263
  // Same tick as the hydrate: a switch in between cannot be missed.
223
264
  if (current !== generation) return;
224
- if (!hydrateSnapshot(queryClient, raw, ACCOUNT_QUERY_CACHE_MAX_AGE) && raw) {
265
+ if (
266
+ !hydrateSnapshot(queryClient, raw, ACCOUNT_QUERY_CACHE_MAX_AGE, {
267
+ shouldRestoreQuery: persistsQuery,
268
+ shouldRestoreMutation: (mutation) => persistsMutationKey(mutation.mutationKey),
269
+ }) &&
270
+ raw
271
+ ) {
225
272
  await storage.removeItem(key).catch(() => undefined);
226
273
  if (current !== generation) return;
227
274
  }
@@ -55,7 +55,7 @@ interface PersistedBlob {
55
55
  clientState: DehydratedState;
56
56
  }
57
57
 
58
- export function rootOf(query: Query): string | null {
58
+ export function rootOf(query: Pick<Query, 'queryKey'>): string | null {
59
59
  const head = query.queryKey[0];
60
60
  return typeof head === 'string' ? head : null;
61
61
  }
@@ -83,6 +83,11 @@ export function hydrateSnapshot(
83
83
  queryClient: QueryClient,
84
84
  raw: string | null,
85
85
  maxAge: number,
86
+ options?: {
87
+ shouldRestoreQuery?: (query: DehydratedState['queries'][number]) => boolean;
88
+ shouldRestoreMutation?: (mutation: DehydratedState['mutations'][number]) => boolean;
89
+ restoreMutations?: boolean;
90
+ },
86
91
  ): boolean {
87
92
  if (!raw) return false;
88
93
  let blob: PersistedBlob;
@@ -100,8 +105,17 @@ export function hydrateSnapshot(
100
105
  }
101
106
  const sameBuild = blob.buster === getOxyBuildId();
102
107
  hydrate(queryClient, {
103
- queries: sameBuild ? blob.clientState.queries : [],
104
- mutations: blob.clientState.mutations ?? [],
108
+ queries: sameBuild
109
+ ? (blob.clientState.queries ?? []).filter(
110
+ (query) => options?.shouldRestoreQuery?.(query) ?? true,
111
+ )
112
+ : [],
113
+ mutations:
114
+ options?.restoreMutations === false
115
+ ? []
116
+ : (blob.clientState.mutations ?? []).filter(
117
+ (mutation) => options?.shouldRestoreMutation?.(mutation) ?? true,
118
+ ),
105
119
  });
106
120
  return true;
107
121
  }
@@ -163,7 +163,14 @@ export const attachQueryPersistence = (
163
163
  const restored = (async () => {
164
164
  const raw = await storage.getItem(QUERY_CACHE_KEY);
165
165
  if (stopped) return;
166
- if (!hydrateSnapshot(queryClient, raw, QUERY_CACHE_MAX_AGE) && raw) {
166
+ if (
167
+ !hydrateSnapshot(queryClient, raw, QUERY_CACHE_MAX_AGE, {
168
+ shouldRestoreQuery: (query) => !account.ownsQuery(query),
169
+ restoreMutations: accountQueries?.clearUnregisteredMutations !== true,
170
+ shouldRestoreMutation: (mutation) => !account.persistsMutationKey(mutation.mutationKey),
171
+ }) &&
172
+ raw
173
+ ) {
167
174
  await storage.removeItem(QUERY_CACHE_KEY);
168
175
  }
169
176
  })()
@@ -20,6 +20,7 @@ import type { FollowStatus } from '@oxy.so/contracts';
20
20
  import { useOxy } from '../context/oxyRuntimeContext';
21
21
  import {
22
22
  isCompleteFollowStatus,
23
+ getFollowTargetGeneration,
23
24
  isFollowedGlobally,
24
25
  UNKNOWN_FOLLOW_STATUS,
25
26
  useFollowTargetStore,
@@ -92,11 +93,14 @@ export function useFollowTarget(
92
93
  // that window 401s and would leave the button stuck reporting an error the
93
94
  // user cannot act on.
94
95
  if (!targetId || !canUsePrivateApi) return;
96
+ const generation = getFollowTargetGeneration();
95
97
  try {
96
98
  const next = await oxyServices.follows.targetStatus(targetId);
99
+ if (generation !== getFollowTargetGeneration()) return;
97
100
  useFollowTargetStore.getState().setStatus(targetId, next);
98
101
  useFollowTargetStore.getState().setError(targetId, undefined);
99
102
  } catch (e) {
103
+ if (generation !== getFollowTargetGeneration()) return;
100
104
  const store = useFollowTargetStore.getState();
101
105
  // Leave `isUnknown` false so the button stays interactive and can retry.
102
106
  // A failed read is not the same as "never asked" — disabling forever would
@@ -127,6 +131,7 @@ export function useFollowTarget(
127
131
  failure: FollowActionErrorCode,
128
132
  ): Promise<boolean> => {
129
133
  if (!targetId) return false;
134
+ const generation = getFollowTargetGeneration();
130
135
  const store = useFollowTargetStore.getState();
131
136
  const previous = store.statuses[targetId];
132
137
  store.setStatus(targetId, optimistic);
@@ -134,9 +139,11 @@ export function useFollowTarget(
134
139
  store.setError(targetId, undefined);
135
140
  try {
136
141
  const settled = await run();
142
+ if (generation !== getFollowTargetGeneration()) return false;
137
143
  if (settled) useFollowTargetStore.getState().setStatus(targetId, settled);
138
144
  return true;
139
145
  } catch (e) {
146
+ if (generation !== getFollowTargetGeneration()) return false;
140
147
  const s = useFollowTargetStore.getState();
141
148
  // Back to what was true, not to a guess. Clearing the entry instead
142
149
  // would make the next render ask again and flash the wrong state.
@@ -144,7 +151,9 @@ export function useFollowTarget(
144
151
  s.setError(targetId, toFollowErrorCode(e, failure));
145
152
  return false;
146
153
  } finally {
147
- useFollowTargetStore.getState().setPending(targetId, false);
154
+ if (generation === getFollowTargetGeneration()) {
155
+ useFollowTargetStore.getState().setPending(targetId, false);
156
+ }
148
157
  }
149
158
  },
150
159
  [targetId],
@@ -6,6 +6,7 @@ import { getStorageKeys, type StorageInterface } from '../utils/storageHelpers';
6
6
  import { handleAuthError, isInvalidSessionError } from '../utils/errorHandlers';
7
7
  import type { OxyServices } from '@oxy.so/core';
8
8
  import type { QueryClient } from '@tanstack/react-query';
9
+ import { isSessionChangedError } from '../utils/sessionChangedError';
9
10
  import { clearQueryCache } from './queryClient';
10
11
  import { isWebBrowser } from '../utils/isWebBrowser';
11
12
  import { resetSessionScopedStores } from '../stores/resetSessionScopedStores';
@@ -188,6 +189,7 @@ export const useSessionManagement = ({
188
189
  });
189
190
  activeRuntime.mergeSessions(deviceSessions, { merge: true });
190
191
  } catch (error) {
192
+ if (isSessionChangedError(error)) throw error;
191
193
  if (isDev()) {
192
194
  console.warn('Failed to synchronize sessions after switch:', error);
193
195
  }
@@ -195,6 +197,7 @@ export const useSessionManagement = ({
195
197
 
196
198
  return user;
197
199
  } catch (error) {
200
+ if (isSessionChangedError(error)) throw error;
198
201
  const invalidSession = isInvalidSessionError(error);
199
202
 
200
203
  if (invalidSession) {
@@ -652,6 +652,7 @@ export function createOxyRuntime(config: OxyRuntimeConfig): OxyRuntime {
652
652
  facts.directory = null;
653
653
  facts.profiles = new Map();
654
654
  facts.pinnedAccountId = null;
655
+ facts.isLoading = false;
655
656
  touch();
656
657
  });
657
658
  },
@@ -190,3 +190,65 @@ describe('resetFollowState', () => {
190
190
  expect(useFollowStore.getState().followingUsers).toEqual({});
191
191
  });
192
192
  });
193
+
194
+ describe('follow operations across account resets', () => {
195
+ it.each(['resolve', 'reject'] as const)(
196
+ 'does not reconcile a previous account toggle when it %s',
197
+ async (outcome) => {
198
+ const { mock, services } = makeServices();
199
+ let resolve!: (result: FollowMutationResult) => void;
200
+ let reject!: (error: Error) => void;
201
+ mock.follows.follow.mockReturnValueOnce(
202
+ new Promise((res, rej) => {
203
+ resolve = res;
204
+ reject = rej;
205
+ }),
206
+ );
207
+ const pending = useFollowStore.getState().toggleFollowUser('u1', services, false);
208
+ useFollowStore.getState().resetFollowState();
209
+ useFollowStore.setState({
210
+ followingUsers: { u1: true },
211
+ loadingUsers: { u1: true },
212
+ errors: { u1: 'read_failed' },
213
+ });
214
+ if (outcome === 'resolve')
215
+ resolve({ message: 'ok', counts: { followers: 10, following: 3 } });
216
+ else reject(new Error('old request failed'));
217
+ expect(await pending).toBe(false);
218
+ const state = useFollowStore.getState();
219
+ expect(state.followingUsers.u1).toBe(true);
220
+ expect(state.loadingUsers.u1).toBe(true);
221
+ expect(state.errors.u1).toBe('read_failed');
222
+ expect(state.followerCounts.u1).toBeUndefined();
223
+ },
224
+ );
225
+
226
+ it.each(['followMany', 'unfollowMany'] as const)(
227
+ 'does not reconcile a previous account %s',
228
+ async (method) => {
229
+ const { services } = makeServices();
230
+ let resolve!: (result: unknown) => void;
231
+ const response = new Promise((res) => {
232
+ resolve = res;
233
+ });
234
+ Object.assign(services.follows, { [method]: jest.fn(() => response) });
235
+ const store = useFollowStore.getState();
236
+ const pending =
237
+ method === 'followMany'
238
+ ? store.followManyUsers(['u1'], services)
239
+ : store.unfollowManyUsers(['u1'], services);
240
+ store.resetFollowState();
241
+ useFollowStore.setState({
242
+ followingUsers: { u1: true },
243
+ loadingUsers: { u1: true },
244
+ errors: { u1: 'read_failed' },
245
+ });
246
+ resolve({ results: [] });
247
+ await expect(pending).rejects.toMatchObject({ code: 'AUTH_SESSION_CHANGED' });
248
+ const state = useFollowStore.getState();
249
+ expect(state.followingUsers.u1).toBe(true);
250
+ expect(state.loadingUsers.u1).toBe(true);
251
+ expect(state.errors.u1).toBe('read_failed');
252
+ },
253
+ );
254
+ });
@@ -6,6 +6,7 @@ import type {
6
6
  FollowMutationResult,
7
7
  } from '@oxy.so/core';
8
8
  import { type FollowErrorCode, toFollowErrorCode } from '../utils/followErrors';
9
+ import { createSessionChangedError } from '../utils/sessionChangedError';
9
10
 
10
11
  interface FollowState {
11
12
  // Tri-state follow map: a MISSING key means UNKNOWN (status not yet resolved),
@@ -206,6 +207,7 @@ export const useFollowStore = create<FollowState>((set, get) => ({
206
207
  });
207
208
  })
208
209
  .finally(() => {
210
+ if (generation !== batchGeneration) return;
209
211
  for (const id of ids) inFlightIds.delete(id);
210
212
  });
211
213
  });
@@ -215,6 +217,7 @@ export const useFollowStore = create<FollowState>((set, get) => ({
215
217
  oxyServices: OxyServices,
216
218
  isCurrentlyFollowing: boolean,
217
219
  ) => {
220
+ const generation = batchGeneration;
218
221
  // Snapshot the prior value for rollback. `undefined` = was UNKNOWN.
219
222
  const priorState = get().followingUsers;
220
223
  const hadPrevious = hasKey(priorState, userId);
@@ -233,6 +236,7 @@ export const useFollowStore = create<FollowState>((set, get) => ({
233
236
  const response: FollowMutationResult = isCurrentlyFollowing
234
237
  ? await oxyServices.follows.unfollow(userId)
235
238
  : await oxyServices.follows.follow(userId);
239
+ if (generation !== batchGeneration) return false;
236
240
 
237
241
  // Reconcile: confirm the optimistic value is authoritative, clear loading.
238
242
  set((state) => ({
@@ -262,6 +266,7 @@ export const useFollowStore = create<FollowState>((set, get) => ({
262
266
  }
263
267
  return true;
264
268
  } catch (error: unknown) {
269
+ if (generation !== batchGeneration) return false;
265
270
  // Roll back to the prior value (or back to UNKNOWN if there was none).
266
271
  set((state) => {
267
272
  const followingUsers = { ...state.followingUsers };
@@ -286,6 +291,7 @@ export const useFollowStore = create<FollowState>((set, get) => ({
286
291
  userIds: string[],
287
292
  oxyServices: OxyServices,
288
293
  ): Promise<BulkFollowResult> => {
294
+ const generation = batchGeneration;
289
295
  // Snapshot prior values for rollback (tri-state: undefined = unknown).
290
296
  const prior = get().followingUsers;
291
297
  const previous = new Map<string, boolean | undefined>();
@@ -308,6 +314,7 @@ export const useFollowStore = create<FollowState>((set, get) => ({
308
314
 
309
315
  try {
310
316
  const result = await oxyServices.follows.followMany(userIds);
317
+ if (generation !== batchGeneration) throw createSessionChangedError();
311
318
  set((state) => {
312
319
  const followingUsers = { ...state.followingUsers };
313
320
  const loadingUsers = { ...state.loadingUsers };
@@ -334,6 +341,7 @@ export const useFollowStore = create<FollowState>((set, get) => ({
334
341
  });
335
342
  return result;
336
343
  } catch (error: unknown) {
344
+ if (generation !== batchGeneration) throw createSessionChangedError();
337
345
  const code = toFollowErrorCode(error, 'update_failed');
338
346
  // Whole batch failed — roll ALL targets back to their prior values.
339
347
  set((state) => {
@@ -359,6 +367,7 @@ export const useFollowStore = create<FollowState>((set, get) => ({
359
367
  userIds: string[],
360
368
  oxyServices: OxyServices,
361
369
  ): Promise<BulkUnfollowResult> => {
370
+ const generation = batchGeneration;
362
371
  // Snapshot prior values for rollback (tri-state: undefined = unknown).
363
372
  const prior = get().followingUsers;
364
373
  const previous = new Map<string, boolean | undefined>();
@@ -381,6 +390,7 @@ export const useFollowStore = create<FollowState>((set, get) => ({
381
390
 
382
391
  try {
383
392
  const result = await oxyServices.follows.unfollowMany(userIds);
393
+ if (generation !== batchGeneration) throw createSessionChangedError();
384
394
  set((state) => {
385
395
  const followingUsers = { ...state.followingUsers };
386
396
  const loadingUsers = { ...state.loadingUsers };
@@ -407,6 +417,7 @@ export const useFollowStore = create<FollowState>((set, get) => ({
407
417
  });
408
418
  return result;
409
419
  } catch (error: unknown) {
420
+ if (generation !== batchGeneration) throw createSessionChangedError();
410
421
  const code = toFollowErrorCode(error, 'update_failed');
411
422
  // Whole batch failed — roll ALL targets back to their prior values.
412
423
  set((state) => {
@@ -454,11 +465,13 @@ export const useFollowStore = create<FollowState>((set, get) => ({
454
465
  });
455
466
  },
456
467
  fetchUserCounts: async (userId: string, oxyServices: OxyServices) => {
468
+ const generation = batchGeneration;
457
469
  set((state) => ({
458
470
  loadingCounts: { ...state.loadingCounts, [userId]: true },
459
471
  }));
460
472
  try {
461
473
  const user = await oxyServices.users.get(userId);
474
+ if (generation !== batchGeneration) return;
462
475
  if (user?._count) {
463
476
  set((state) => ({
464
477
  followerCounts: {
@@ -477,6 +490,7 @@ export const useFollowStore = create<FollowState>((set, get) => ({
477
490
  }));
478
491
  }
479
492
  } catch (error: unknown) {
493
+ if (generation !== batchGeneration) return;
480
494
  set((state) => ({
481
495
  loadingCounts: { ...state.loadingCounts, [userId]: false },
482
496
  }));
@@ -29,6 +29,13 @@ import type { FollowErrorCode } from '../utils/followErrors';
29
29
  /** The status of one target, or `undefined` when it has never been read. */
30
30
  type StatusMap = Record<string, FollowStatus | undefined>;
31
31
 
32
+ let followTargetGeneration = 0;
33
+
34
+ /** The generation changes whenever account-scoped follow state is reset. */
35
+ export function getFollowTargetGeneration(): number {
36
+ return followTargetGeneration;
37
+ }
38
+
32
39
  interface FollowTargetState {
33
40
  statuses: StatusMap;
34
41
  /** In-flight writes, per target. Reads do not set this — only mutations do. */
@@ -80,7 +87,10 @@ export const useFollowTargetStore = create<FollowTargetState>((set) => ({
80
87
  // account switch the whole map is wrong, not stale — the next user's follows
81
88
  // are a different set entirely, and showing one user their previous
82
89
  // account's follow state is a privacy failure rather than a rendering one.
83
- reset: () => set({ statuses: {}, pending: {}, errors: {} }),
90
+ reset: () => {
91
+ followTargetGeneration += 1;
92
+ set({ statuses: {}, pending: {}, errors: {} });
93
+ },
84
94
  }));
85
95
 
86
96
  /**
@@ -1,4 +1,5 @@
1
1
  import type { ApiError } from '@oxy.so/core';
2
+ import { isSessionChangedError } from './sessionChangedError';
2
3
 
3
4
  type ErrorWithMessage = {
4
5
  message?: string;
@@ -52,6 +53,7 @@ const getResponseStatus = (error: unknown): number | undefined => {
52
53
  * This centralizes 401 detection across different fetch clients.
53
54
  */
54
55
  export const isInvalidSessionError = (error: unknown): boolean => {
56
+ if (isSessionChangedError(error)) return false;
55
57
  const status = getResponseStatus(error);
56
58
  if (status === 401) {
57
59
  return true;
@@ -1,3 +1,5 @@
1
+ import { isSessionChangedError } from './sessionChangedError';
2
+
1
3
  /**
2
4
  * What a failed follow read or write leaves behind: a stable CODE, never text.
3
5
  *
@@ -43,6 +45,7 @@ const OFFLINE_CODES = new Set(['NETWORK_ERROR', 'CONNECTION_FAILED', 'TIMEOUT'])
43
45
  * Anything the cause does not explain falls back to the caller's `action`.
44
46
  */
45
47
  export function toFollowErrorCode(error: unknown, action: FollowActionErrorCode): FollowErrorCode {
48
+ if (isSessionChangedError(error)) return action;
46
49
  const { status, code } = (error && typeof error === 'object' ? error : {}) as {
47
50
  status?: unknown;
48
51
  code?: unknown;
@@ -0,0 +1,14 @@
1
+ /** A locally superseded request is not a server rejection of the new session. */
2
+ export function isSessionChangedError(error: unknown): boolean {
3
+ return (
4
+ !!error && typeof error === 'object' && 'code' in error && error.code === 'AUTH_SESSION_CHANGED'
5
+ );
6
+ }
7
+
8
+ /** A locally superseded operation must not be mistaken for a server 401. */
9
+ export function createSessionChangedError(): Error & { code: string; status: number } {
10
+ return Object.assign(new Error('The request session changed'), {
11
+ code: 'AUTH_SESSION_CHANGED',
12
+ status: 401,
13
+ });
14
+ }
@@ -1,4 +1,5 @@
1
1
  import type { ClientSession } from '@oxy.so/core';
2
+ import { isSessionChangedError } from './sessionChangedError';
2
3
 
3
4
  interface DeviceSession {
4
5
  sessionId: string;
@@ -73,6 +74,7 @@ export const fetchSessionsWithFallback = async (
73
74
  const deviceSessions = await oxyServices.devices.sessions(sessionId);
74
75
  return mapSessionsToClient(deviceSessions, fallbackDeviceId, fallbackUserId);
75
76
  } catch (error) {
77
+ if (isSessionChangedError(error)) throw error;
76
78
  if (__DEV__ && logger) {
77
79
  logger('Failed to get device sessions, falling back to user sessions', error);
78
80
  }