👁️
1/**
2 * Utilities for optimistic updates in TanStack Query mutations
3 * Each function returns a thunk that performs the update and returns a rollback
4 */
5
6import type {
7 InfiniteData,
8 QueryClient,
9 QueryKey,
10} from "@tanstack/react-query";
11import type { BacklinkRecord, BacklinksResponse } from "./constellation-client";
12
13export type Rollback = () => void;
14export type OptimisticUpdate = () => Promise<Rollback>;
15
16/**
17 * Combine multiple rollback functions into one
18 * Executes in reverse order (last update rolled back first)
19 */
20export function combineRollbacks(rollbacks: Rollback[]): Rollback {
21 return () => {
22 for (let i = rollbacks.length - 1; i >= 0; i--) {
23 rollbacks[i]();
24 }
25 };
26}
27
28/**
29 * Run multiple optimistic updates sequentially and combine their rollbacks
30 * Updates run in order, rollbacks execute in reverse order
31 */
32export async function runOptimistic(
33 updates: OptimisticUpdate[],
34): Promise<Rollback> {
35 const rollbacks: Rollback[] = [];
36 for (const update of updates) {
37 rollbacks.push(await update());
38 }
39 return combineRollbacks(rollbacks);
40}
41
42/**
43 * No-op update for use in ternaries
44 * Example: condition ? optimisticFoo(...) : skip()
45 */
46export function skip(): OptimisticUpdate {
47 return () => Promise.resolve(() => {});
48}
49
50/**
51 * Conditionally run an update only if value is truthy
52 * Passes the narrowed value to the update factory
53 */
54export function when<T>(
55 value: T | null | undefined | false,
56 update: (value: T) => OptimisticUpdate,
57): OptimisticUpdate {
58 if (!value) return skip();
59 return update(value);
60}
61
62/**
63 * Cancel queries and snapshot current value before optimistic update
64 */
65async function prepareOptimisticUpdate<T>(
66 queryClient: QueryClient,
67 queryKey: QueryKey,
68): Promise<T | undefined> {
69 await queryClient.cancelQueries({ queryKey });
70 return queryClient.getQueryData<T>(queryKey);
71}
72
73/**
74 * Create a rollback function that properly handles undefined previous values
75 * setQueryData(key, undefined) doesn't clear the entry, so we use removeQueries instead
76 */
77function createRollback<T>(
78 queryClient: QueryClient,
79 queryKey: QueryKey,
80 previous: T | undefined,
81): Rollback {
82 return () => {
83 if (previous === undefined) {
84 queryClient.removeQueries({ queryKey, exact: true });
85 } else {
86 queryClient.setQueryData<T>(queryKey, previous);
87 }
88 };
89}
90
91/**
92 * Optimistically toggle a boolean value
93 * Can accept a closure that receives queryClient and returns:
94 * - boolean: set to that value
95 * - undefined: skip the update (no-op rollback)
96 */
97export function optimisticBoolean(
98 queryClient: QueryClient,
99 queryKey: QueryKey,
100 newValue: boolean | ((qc: QueryClient) => boolean | undefined),
101): OptimisticUpdate {
102 return async () => {
103 const previous = await prepareOptimisticUpdate<boolean>(
104 queryClient,
105 queryKey,
106 );
107
108 const resolved =
109 typeof newValue === "function" ? newValue(queryClient) : newValue;
110
111 if (resolved === undefined) {
112 return () => {};
113 }
114
115 queryClient.setQueryData<boolean>(queryKey, resolved);
116 return createRollback(queryClient, queryKey, previous);
117 };
118}
119
120/**
121 * Optimistically update a count (increment or decrement)
122 * Clamps to 0 minimum
123 */
124export function optimisticCount(
125 queryClient: QueryClient,
126 queryKey: QueryKey,
127 delta: number,
128): OptimisticUpdate {
129 return async () => {
130 const previous = await prepareOptimisticUpdate<number>(
131 queryClient,
132 queryKey,
133 );
134 queryClient.setQueryData<number>(queryKey, (old) =>
135 Math.max(0, (old ?? 0) + delta),
136 );
137 return createRollback(queryClient, queryKey, previous);
138 };
139}
140
141/**
142 * Optimistically toggle a boolean and its associated count together
143 * Common pattern for like/save operations
144 */
145export function optimisticToggle(
146 queryClient: QueryClient,
147 boolKey: QueryKey,
148 countKey: QueryKey,
149 newBoolValue: boolean,
150): OptimisticUpdate {
151 return async () => {
152 const boolRollback = await optimisticBoolean(
153 queryClient,
154 boolKey,
155 newBoolValue,
156 )();
157 const countRollback = await optimisticCount(
158 queryClient,
159 countKey,
160 newBoolValue ? 1 : -1,
161 )();
162 return combineRollbacks([boolRollback, countRollback]);
163 };
164}
165
166/**
167 * Optimistically add or remove a record from a backlinks infinite query
168 * Adds to first page (if cache exists), removes from any page.
169 * Does NOT seed empty cache - that would show incomplete data when the query is first fetched.
170 */
171export function optimisticBacklinks(
172 queryClient: QueryClient,
173 queryKey: QueryKey,
174 op: "add" | "remove",
175 record: BacklinkRecord,
176): OptimisticUpdate {
177 return async () => {
178 const previous = await prepareOptimisticUpdate<
179 InfiniteData<BacklinksResponse>
180 >(queryClient, queryKey);
181
182 queryClient.setQueryData<InfiniteData<BacklinksResponse>>(
183 queryKey,
184 (old) => {
185 // Don't modify cache if it doesn't exist - let the query fetch real data
186 if (!old) return old;
187
188 if (op === "remove") {
189 return {
190 ...old,
191 pages: old.pages.map((page, i) =>
192 i === 0
193 ? {
194 ...page,
195 total: Math.max(0, page.total - 1),
196 records: page.records.filter(
197 (r) =>
198 !(
199 r.did === record.did &&
200 r.collection === record.collection &&
201 r.rkey === record.rkey
202 ),
203 ),
204 }
205 : page,
206 ),
207 };
208 }
209
210 // Add to first page
211 return {
212 ...old,
213 pages: old.pages.map((page, i) =>
214 i === 0
215 ? {
216 ...page,
217 total: page.total + 1,
218 records: [record, ...page.records],
219 }
220 : page,
221 ),
222 };
223 },
224 );
225
226 return createRollback(queryClient, queryKey, previous);
227 };
228}
229
230/**
231 * Optimistically update a single record in the cache
232 */
233export function optimisticRecord<T>(
234 queryClient: QueryClient,
235 queryKey: QueryKey,
236 updater: T | ((old: T | undefined) => T | undefined),
237): OptimisticUpdate {
238 return async () => {
239 const previous = await prepareOptimisticUpdate<T>(queryClient, queryKey);
240
241 if (typeof updater === "function") {
242 queryClient.setQueryData<T>(
243 queryKey,
244 updater as (old: T | undefined) => T | undefined,
245 );
246 } else {
247 queryClient.setQueryData<T>(queryKey, updater);
248 }
249
250 return createRollback(queryClient, queryKey, previous);
251 };
252}
253
254/**
255 * Page shape for record list infinite queries (decks, lists, etc.)
256 */
257export interface RecordPage<T> {
258 records: Array<{ uri: string; cid: string; value: T }>;
259 cursor?: string;
260}
261
262/**
263 * Optimistically update a record in an infinite query by URI match
264 * Used when you have both a single record query and a list query containing it
265 */
266export function optimisticInfiniteRecord<T>(
267 queryClient: QueryClient,
268 queryKey: QueryKey,
269 uriSuffix: string,
270 newValue: T,
271): OptimisticUpdate {
272 return async () => {
273 const previous = await prepareOptimisticUpdate<InfiniteData<RecordPage<T>>>(
274 queryClient,
275 queryKey,
276 );
277
278 queryClient.setQueryData<InfiniteData<RecordPage<T>>>(queryKey, (old) => {
279 if (!old) return old;
280 return {
281 ...old,
282 pages: old.pages.map((page) => ({
283 ...page,
284 records: page.records.map((record) =>
285 record.uri.endsWith(uriSuffix)
286 ? { ...record, value: newValue }
287 : record,
288 ),
289 })),
290 };
291 });
292
293 return createRollback(queryClient, queryKey, previous);
294 };
295}
296
297/**
298 * Optimistically update both a single record and its entry in an infinite list
299 * Common pattern for repo record mutations
300 */
301export function optimisticRecordWithIndex<T>(
302 queryClient: QueryClient,
303 recordKey: QueryKey,
304 indexKey: QueryKey,
305 rkey: string,
306 newValue: T,
307): OptimisticUpdate {
308 return async () => {
309 const recordRollback = await optimisticRecord(
310 queryClient,
311 recordKey,
312 newValue,
313 )();
314 const indexRollback = await optimisticInfiniteRecord(
315 queryClient,
316 indexKey,
317 `/${rkey}`,
318 newValue,
319 )();
320 return combineRollbacks([recordRollback, indexRollback]);
321 };
322}