@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.
- package/lib/commonjs/ui/context/commitSessionFlow.js +4 -0
- package/lib/commonjs/ui/context/commitSessionFlow.js.map +1 -1
- package/lib/commonjs/ui/context/hooks/useAuthOperations.js +17 -2
- package/lib/commonjs/ui/context/hooks/useAuthOperations.js.map +1 -1
- package/lib/commonjs/ui/context/oxyContextHelpers.js +2 -0
- package/lib/commonjs/ui/context/oxyContextHelpers.js.map +1 -1
- package/lib/commonjs/ui/hooks/accountQueryPersistence.js +19 -8
- package/lib/commonjs/ui/hooks/accountQueryPersistence.js.map +1 -1
- package/lib/commonjs/ui/hooks/persistedQueryCache.js +3 -3
- package/lib/commonjs/ui/hooks/persistedQueryCache.js.map +1 -1
- package/lib/commonjs/ui/hooks/queryClient.js +5 -1
- package/lib/commonjs/ui/hooks/queryClient.js.map +1 -1
- package/lib/commonjs/ui/hooks/useFollowTarget.js +9 -1
- package/lib/commonjs/ui/hooks/useFollowTarget.js.map +1 -1
- package/lib/commonjs/ui/hooks/useSessionManagement.js +3 -0
- package/lib/commonjs/ui/hooks/useSessionManagement.js.map +1 -1
- package/lib/commonjs/ui/runtime/createOxyRuntime.js +1 -0
- package/lib/commonjs/ui/runtime/createOxyRuntime.js.map +1 -1
- package/lib/commonjs/ui/stores/followStore.js +14 -0
- package/lib/commonjs/ui/stores/followStore.js.map +1 -1
- package/lib/commonjs/ui/stores/followTargetStore.js +15 -5
- package/lib/commonjs/ui/stores/followTargetStore.js.map +1 -1
- package/lib/commonjs/ui/utils/errorHandlers.js +2 -0
- package/lib/commonjs/ui/utils/errorHandlers.js.map +1 -1
- package/lib/commonjs/ui/utils/followErrors.js +2 -0
- package/lib/commonjs/ui/utils/followErrors.js.map +1 -1
- package/lib/commonjs/ui/utils/sessionChangedError.js +20 -0
- package/lib/commonjs/ui/utils/sessionChangedError.js.map +1 -0
- package/lib/commonjs/ui/utils/sessionHelpers.js +2 -0
- package/lib/commonjs/ui/utils/sessionHelpers.js.map +1 -1
- package/lib/module/ui/context/commitSessionFlow.js +4 -0
- package/lib/module/ui/context/commitSessionFlow.js.map +1 -1
- package/lib/module/ui/context/hooks/useAuthOperations.js +17 -2
- package/lib/module/ui/context/hooks/useAuthOperations.js.map +1 -1
- package/lib/module/ui/context/oxyContextHelpers.js +3 -0
- package/lib/module/ui/context/oxyContextHelpers.js.map +1 -1
- package/lib/module/ui/hooks/accountQueryPersistence.js +19 -8
- package/lib/module/ui/hooks/accountQueryPersistence.js.map +1 -1
- package/lib/module/ui/hooks/persistedQueryCache.js +3 -3
- package/lib/module/ui/hooks/persistedQueryCache.js.map +1 -1
- package/lib/module/ui/hooks/queryClient.js +5 -1
- package/lib/module/ui/hooks/queryClient.js.map +1 -1
- package/lib/module/ui/hooks/useFollowTarget.js +10 -2
- package/lib/module/ui/hooks/useFollowTarget.js.map +1 -1
- package/lib/module/ui/hooks/useSessionManagement.js +3 -0
- package/lib/module/ui/hooks/useSessionManagement.js.map +1 -1
- package/lib/module/ui/runtime/createOxyRuntime.js +1 -0
- package/lib/module/ui/runtime/createOxyRuntime.js.map +1 -1
- package/lib/module/ui/stores/followStore.js +14 -0
- package/lib/module/ui/stores/followStore.js.map +1 -1
- package/lib/module/ui/stores/followTargetStore.js +14 -5
- package/lib/module/ui/stores/followTargetStore.js.map +1 -1
- package/lib/module/ui/utils/errorHandlers.js +2 -0
- package/lib/module/ui/utils/errorHandlers.js.map +1 -1
- package/lib/module/ui/utils/followErrors.js +3 -0
- package/lib/module/ui/utils/followErrors.js.map +1 -1
- package/lib/module/ui/utils/sessionChangedError.js +15 -0
- package/lib/module/ui/utils/sessionChangedError.js.map +1 -0
- package/lib/module/ui/utils/sessionHelpers.js +3 -0
- package/lib/module/ui/utils/sessionHelpers.js.map +1 -1
- package/lib/typescript/commonjs/ui/context/commitSessionFlow.d.ts.map +1 -1
- package/lib/typescript/commonjs/ui/context/hooks/useAuthOperations.d.ts.map +1 -1
- package/lib/typescript/commonjs/ui/context/oxyContextHelpers.d.ts.map +1 -1
- package/lib/typescript/commonjs/ui/hooks/accountQueryPersistence.d.ts +30 -3
- package/lib/typescript/commonjs/ui/hooks/accountQueryPersistence.d.ts.map +1 -1
- package/lib/typescript/commonjs/ui/hooks/persistedQueryCache.d.ts +7 -3
- package/lib/typescript/commonjs/ui/hooks/persistedQueryCache.d.ts.map +1 -1
- package/lib/typescript/commonjs/ui/hooks/queryClient.d.ts.map +1 -1
- package/lib/typescript/commonjs/ui/hooks/useFollowTarget.d.ts.map +1 -1
- package/lib/typescript/commonjs/ui/hooks/useSessionManagement.d.ts.map +1 -1
- package/lib/typescript/commonjs/ui/icons/MaterialCommunityIcons.d.ts +1 -1
- package/lib/typescript/commonjs/ui/runtime/createOxyRuntime.d.ts.map +1 -1
- package/lib/typescript/commonjs/ui/stores/followStore.d.ts.map +1 -1
- package/lib/typescript/commonjs/ui/stores/followTargetStore.d.ts +2 -0
- package/lib/typescript/commonjs/ui/stores/followTargetStore.d.ts.map +1 -1
- package/lib/typescript/commonjs/ui/utils/errorHandlers.d.ts.map +1 -1
- package/lib/typescript/commonjs/ui/utils/followErrors.d.ts.map +1 -1
- package/lib/typescript/commonjs/ui/utils/sessionChangedError.d.ts +8 -0
- package/lib/typescript/commonjs/ui/utils/sessionChangedError.d.ts.map +1 -0
- package/lib/typescript/commonjs/ui/utils/sessionHelpers.d.ts.map +1 -1
- package/lib/typescript/module/ui/context/commitSessionFlow.d.ts.map +1 -1
- package/lib/typescript/module/ui/context/hooks/useAuthOperations.d.ts.map +1 -1
- package/lib/typescript/module/ui/context/oxyContextHelpers.d.ts.map +1 -1
- package/lib/typescript/module/ui/hooks/accountQueryPersistence.d.ts +30 -3
- package/lib/typescript/module/ui/hooks/accountQueryPersistence.d.ts.map +1 -1
- package/lib/typescript/module/ui/hooks/persistedQueryCache.d.ts +7 -3
- package/lib/typescript/module/ui/hooks/persistedQueryCache.d.ts.map +1 -1
- package/lib/typescript/module/ui/hooks/queryClient.d.ts.map +1 -1
- package/lib/typescript/module/ui/hooks/useFollowTarget.d.ts.map +1 -1
- package/lib/typescript/module/ui/hooks/useSessionManagement.d.ts.map +1 -1
- package/lib/typescript/module/ui/icons/MaterialCommunityIcons.d.ts +1 -1
- package/lib/typescript/module/ui/runtime/createOxyRuntime.d.ts.map +1 -1
- package/lib/typescript/module/ui/stores/followStore.d.ts.map +1 -1
- package/lib/typescript/module/ui/stores/followTargetStore.d.ts +2 -0
- package/lib/typescript/module/ui/stores/followTargetStore.d.ts.map +1 -1
- package/lib/typescript/module/ui/utils/errorHandlers.d.ts.map +1 -1
- package/lib/typescript/module/ui/utils/followErrors.d.ts.map +1 -1
- package/lib/typescript/module/ui/utils/sessionChangedError.d.ts +8 -0
- package/lib/typescript/module/ui/utils/sessionChangedError.d.ts.map +1 -0
- package/lib/typescript/module/ui/utils/sessionHelpers.d.ts.map +1 -1
- package/package.json +2 -2
- package/src/ui/context/__tests__/commitSessionFlow.test.ts +36 -0
- package/src/ui/context/commitSessionFlow.ts +4 -0
- package/src/ui/context/hooks/useAuthOperations.ts +14 -2
- package/src/ui/context/oxyContextHelpers.ts +3 -0
- package/src/ui/hooks/accountQueryPersistence.ts +60 -13
- package/src/ui/hooks/persistedQueryCache.ts +17 -3
- package/src/ui/hooks/queryClient.ts +8 -1
- package/src/ui/hooks/useFollowTarget.ts +10 -1
- package/src/ui/hooks/useSessionManagement.ts +3 -0
- package/src/ui/runtime/createOxyRuntime.ts +1 -0
- package/src/ui/stores/__tests__/followStore.test.ts +62 -0
- package/src/ui/stores/followStore.ts +14 -0
- package/src/ui/stores/followTargetStore.ts +11 -1
- package/src/ui/utils/errorHandlers.ts +2 -0
- package/src/ui/utils/followErrors.ts +3 -0
- package/src/ui/utils/sessionChangedError.ts +14 -0
- package/src/ui/utils/sessionHelpers.ts +2 -0
|
@@ -54,7 +54,9 @@ export interface AccountQueryMeta {
|
|
|
54
54
|
persist: 'account';
|
|
55
55
|
}
|
|
56
56
|
|
|
57
|
-
|
|
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
|
-
|
|
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:
|
|
110
|
+
ownsQuery: (query: QueryIdentity) => boolean;
|
|
91
111
|
/** Written to the account's blob. */
|
|
92
|
-
persistsQuery: (query:
|
|
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
|
|
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:
|
|
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
|
-
|
|
130
|
-
|
|
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 } =
|
|
203
|
+
const { ownsQuery, persistsQuery, ownsMutation, persistsMutation, persistsMutationKey } =
|
|
204
|
+
accountQueryOwnership(config);
|
|
164
205
|
const filters: PersistFilters = {
|
|
165
206
|
persistsQuery,
|
|
166
|
-
shouldDehydrateMutation: (mutation) => mutation.state.isPaused &&
|
|
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 (
|
|
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
|
|
104
|
-
|
|
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 (
|
|
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
|
-
|
|
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) {
|
|
@@ -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: () =>
|
|
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
|
}
|