/** * Optimistic Updates Utility * FE-API-018: Utilities for optimistic UI updates * * Provides helpers for implementing optimistic updates in React components * with automatic rollback on error */ import { QueryClient } from '@tanstack/react-query'; /** * Options for optimistic update */ // Basic Generic Types export interface OptimisticContext { previousValues: Array<{ queryKey: (string | number)[]; data: unknown }>; } /** * Options for optimistic update */ export interface OptimisticUpdateOptions< TData = unknown, TVariables = unknown, > { /** QueryClient instance */ queryClient: QueryClient; /** Query keys to update optimistically */ queryKeys: (string | number)[][]; /** Function to generate optimistic data from variables */ optimisticData: (variables: TVariables) => TData; /** Function to update query data */ updateQueryData?: ( oldData: TData | undefined, variables: TVariables, queryClient: QueryClient, ) => TData; /** Function to rollback on error */ rollback?: (variables: TVariables, queryClient: QueryClient) => void; } /** * Creates an optimistic update configuration for useMutation * * @example * ```typescript * const queryClient = useQueryClient(); * const mutation = useMutation({ * mutationFn: updatePlaylist, * ...createOptimisticUpdate({ * queryClient, * queryKeys: [['playlist', playlistId], ['playlists']], * optimisticData: (variables) => ({ * ...currentPlaylist, * ...variables, * }), * }), * }); * ``` */ export function createOptimisticUpdate( options: OptimisticUpdateOptions & { queryClient: QueryClient; }, ) { const { queryClient, queryKeys, optimisticData, updateQueryData, rollback } = options; return { onMutate: async (variables: TVariables): Promise => { // Cancel outgoing refetches to avoid overwriting optimistic update // Snapshot previous values for rollback const previousValues: Array<{ queryKey: (string | number)[]; data: unknown; }> = []; // Cancel queries and snapshot previous values for (const queryKey of queryKeys) { await queryClient.cancelQueries({ queryKey }); const previousData = queryClient.getQueryData(queryKey); previousValues.push({ queryKey, data: previousData }); // Apply optimistic update if (updateQueryData) { queryClient.setQueryData(queryKey, (old) => updateQueryData(old, variables, queryClient), ); } else { // Default: replace with optimistic data queryClient.setQueryData(queryKey, optimisticData(variables)); } } // Return context with previous values for rollback return { previousValues }; }, onError: ( _error: Error, _variables: TVariables, context: OptimisticContext | undefined, ) => { if (context?.previousValues) { // Restore previous values for (const { queryKey, data } of context.previousValues) { queryClient.setQueryData(queryKey, data); } } // Call custom rollback if provided if (rollback) { rollback(_variables, queryClient); } }, onSettled: () => { // Always refetch after error or success to ensure consistency for (const queryKey of queryKeys) { queryClient.invalidateQueries({ queryKey }); } }, }; } /** * Helper for optimistic updates with array operations (add, remove, update) */ /** * Helper for optimistic updates with array operations (add, remove, update) */ export interface ArrayOptimisticUpdateOptions< TItem = unknown, TVariables = unknown, TData = unknown, > { /** QueryClient instance */ queryClient: QueryClient; /** Query keys to update */ queryKeys: (string | number)[][]; /** Query key that contains the array */ arrayQueryKey: (string | number)[]; /** Function to get the array from query data */ getArray?: (data: TData) => TItem[]; /** Operation type */ operation: 'add' | 'remove' | 'update'; /** Function to generate optimistic item */ optimisticItem?: (variables: TVariables) => TItem; /** Function to match item for remove/update */ matchItem?: (item: TItem, variables: TVariables) => boolean; /** Function to update item */ updateItem?: (item: TItem, variables: TVariables) => TItem; } /** * Creates optimistic update for array operations (add, remove, update) * * @example * ```typescript * // Add item * const addMutation = useMutation({ * mutationFn: addTrackToPlaylist, * ...createArrayOptimisticUpdate({ * queryKeys: [['playlist', playlistId]], * arrayQueryKey: ['playlist', playlistId, 'tracks'], * operation: 'add', * optimisticItem: (variables) => ({ * id: `temp-${Date.now()}`, * track_id: variables.trackId, * ...variables, * }), * }), * }); * // ... * ``` */ /** * Helper to safely extract an array from unknown data */ function safeGetArray(data: unknown): unknown[] { if (Array.isArray(data)) return data; if (data && typeof data === 'object') { if ( 'items' in data && Array.isArray((data as Record).items) ) { return (data as Record).items as unknown[]; } // Check for tracks property common in this app if ( 'tracks' in data && Array.isArray((data as Record).tracks) ) { return (data as Record).tracks as unknown[]; } } return []; } /** * Creates optimistic update for array operations (add, remove, update) * * @example * ```typescript * // Add item * const addMutation = useMutation({ * mutationFn: addTrackToPlaylist, * ...createArrayOptimisticUpdate({ * queryKeys: [['playlist', playlistId]], * arrayQueryKey: ['playlist', playlistId, 'tracks'], * operation: 'add', * optimisticItem: (variables) => ({ * id: `temp-${Date.now()}`, * track_id: variables.trackId, * ...variables, * }), * }), * }); * // ... * ``` */ export function createArrayOptimisticUpdate< TItem = unknown, TVariables = unknown, TData = unknown, >(options: ArrayOptimisticUpdateOptions) { const { queryClient, queryKeys, arrayQueryKey, getArray = (data: unknown) => safeGetArray(data) as TItem[], operation, optimisticItem, matchItem, updateItem, } = options; return { onMutate: async (variables: TVariables): Promise => { const previousValues: Array<{ queryKey: (string | number)[]; data: unknown; }> = []; // Cancel queries and snapshot for (const queryKey of queryKeys) { await queryClient.cancelQueries({ queryKey }); const previousData = queryClient.getQueryData(queryKey); previousValues.push({ queryKey, data: previousData }); } // Apply optimistic update to array for (const queryKey of queryKeys) { queryClient.setQueryData(queryKey, (old) => { if (!old) return old; // We treat old data as unknown initially const oldData = old as unknown; const array = getArray(oldData as TData); let newArray: TItem[]; switch (operation) { case 'add': if (optimisticItem) { newArray = [...array, optimisticItem(variables)]; } else { newArray = array; } break; case 'remove': if (matchItem) { newArray = array.filter( (item: TItem) => !matchItem(item, variables), ); } else { newArray = array; } break; case 'update': if (matchItem && updateItem) { newArray = array.map((item: TItem) => matchItem(item, variables) ? updateItem(item, variables) : item, ); } else { newArray = array; } break; default: newArray = array; } // Update the array in the data structure if (Array.isArray(oldData)) { return newArray as unknown as TData; } if (oldData && typeof oldData === 'object') { const oldObj = oldData as Record; if ('items' in oldObj) { return { ...oldObj, items: newArray } as unknown as TData; } if ('tracks' in oldObj) { return { ...oldObj, tracks: newArray } as unknown as TData; } // Fallback to arrayQueryKey path const lastKey = arrayQueryKey[arrayQueryKey.length - 1]; if (lastKey) { return { ...oldObj, [lastKey]: newArray } as unknown as TData; } } return oldData as TData; }); } return { previousValues }; }, onError: ( _error: Error, _variables: TVariables, context: OptimisticContext | undefined, ) => { if (context?.previousValues) { for (const { queryKey, data } of context.previousValues) { queryClient.setQueryData(queryKey, data); } } }, onSettled: () => { for (const queryKey of queryKeys) { queryClient.invalidateQueries({ queryKey }); } }, }; } /** * Interface for entities that can be toggled (like/follow) */ interface ToggleableEntity { is_liked?: boolean; isLiked?: boolean; is_following?: boolean; isFollowing?: boolean; like_count?: number; likeCount?: number; follower_count?: number; followerCount?: number; [key: string]: unknown; } /** * Helper for toggle operations (like/unlike, follow/unfollow) */ export interface ToggleOptimisticUpdateOptions { /** QueryClient instance */ queryClient: QueryClient; /** Query keys to update */ queryKeys: (string | number)[][]; /** Current state value */ currentValue: boolean; /** Function to get the value from query data */ getValue?: (data: TData) => boolean; /** Function to get the count from query data */ getCount?: (data: TData) => number; /** New state value (opposite of currentValue) */ newValue?: boolean; /** Whether to update count */ updateCount?: boolean; /** Count increment/decrement */ countDelta?: number; } /** * Creates optimistic update for toggle operations */ export function createToggleOptimisticUpdate< TVariables = unknown, TData = unknown, >(options: ToggleOptimisticUpdateOptions) { const { queryClient, queryKeys, currentValue, getValue: _getValue, getCount: _getCount, newValue = !currentValue, updateCount = true, countDelta = 1, } = options; // No explicit getters used in the default implementation below, they operate on well-known properties directly. // We keep the parameters in options for future extensibility but ignore them in the body if not used. return { onMutate: async (_variables: TVariables): Promise => { const previousValues: Array<{ queryKey: (string | number)[]; data: unknown; }> = []; for (const queryKey of queryKeys) { await queryClient.cancelQueries({ queryKey }); const previousData = queryClient.getQueryData(queryKey); previousValues.push({ queryKey, data: previousData }); queryClient.setQueryData(queryKey, (old) => { if (!old) return old; // Safe cast to ToggleableEntity for property access const entity = old as unknown as ToggleableEntity; const updated = { ...entity }; // Update boolean value if ('is_liked' in updated) { updated.is_liked = newValue; } if ('isLiked' in updated) { updated.isLiked = newValue; } if ('is_following' in updated) { updated.is_following = newValue; } if ('isFollowing' in updated) { updated.isFollowing = newValue; } // Update count if (updateCount) { if ('like_count' in updated) { updated.like_count = Math.max( 0, (updated.like_count || 0) + countDelta, ); } if ('likeCount' in updated) { updated.likeCount = Math.max( 0, (updated.likeCount || 0) + countDelta, ); } if ('follower_count' in updated) { updated.follower_count = Math.max( 0, (updated.follower_count || 0) + countDelta, ); } if ('followerCount' in updated) { updated.followerCount = Math.max( 0, (updated.followerCount || 0) + countDelta, ); } } return updated as unknown as TData; }); } return { previousValues }; }, onError: ( _error: Error, _variables: TVariables, context: OptimisticContext | undefined, ) => { if (context?.previousValues) { for (const { queryKey, data } of context.previousValues) { queryClient.setQueryData(queryKey, data); } } }, onSettled: () => { for (const queryKey of queryKeys) { queryClient.invalidateQueries({ queryKey }); } }, }; }