👁️
at dev 322 lines 7.9 kB view raw
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}