/** * Optimistic Updates Utility * FE-API-018: Utilities for optimistic UI updates * Edge 6.2: Enhanced with conflict handling for data conflicts * * Provides helpers for implementing optimistic updates in React components * with automatic rollback on error and conflict resolution */ import { QueryClient } from '@tanstack/react-query'; import { AxiosError } from 'axios'; import { parseApiError } from './apiErrorHandler'; import { logger } from './logger'; import toast from './toast'; /** * Options for optimistic update */ // Basic Generic Types export interface OptimisticContext { previousValues: Array<{ queryKey: (string | number)[]; data: unknown }>; } /** * Edge 6.2: Check if an error is a conflict error (HTTP 409) */ function isConflictError(error: unknown): boolean { if (error instanceof AxiosError) { return error.response?.status === 409; } // Check if it's an ApiError with code 409 const apiError = parseApiError(error); const code = apiError.code; // Handle both number and string codes (code can be number or string) if (code === 409) { return true; } // Handle string codes if (typeof code === 'string' && code === '409') { return true; } // Fallback: convert to string and compare return String(code) === '409'; } /** * Edge 6.2: Get conflict message from error */ function getConflictMessage(error: unknown): string { const apiError = parseApiError(error); if (apiError.message && apiError.message.toLowerCase().includes('conflict')) { return apiError.message; } return 'Une modification simultanée a été détectée. Les données ont été mises à jour.'; } /** * 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; /** Edge 6.2: Function to handle conflicts (409 errors) - if not provided, uses default rollback */ onConflict?: ( error: unknown, variables: TVariables, context: OptimisticContext | undefined, queryClient: QueryClient, ) => void; /** Edge 6.2: Whether to show conflict message to user (default: true) */ showConflictMessage?: boolean; } /** * 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, onConflict, showConflictMessage = true, } = 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, ) => { // Edge 6.2: Handle conflicts (409 errors) specially const isConflict = isConflictError(error); if (isConflict && onConflict) { // Use custom conflict handler if provided try { onConflict(error, variables, context, queryClient); } catch (conflictError) { logger.error('[OptimisticUpdate] Error in conflict handler', { error: conflictError instanceof Error ? conflictError.message : String(conflictError), }); // Fall back to default rollback on conflict handler error if (context?.previousValues) { for (const { queryKey, data } of context.previousValues) { queryClient.setQueryData(queryKey, data); } } } } else { // Default error handling: rollback optimistic update if (context?.previousValues) { // Restore previous values for (const { queryKey, data } of context.previousValues) { queryClient.setQueryData(queryKey, data); } } // Edge 6.2: Show conflict message to user if (isConflict && showConflictMessage) { const conflictMessage = getConflictMessage(error); toast.error(conflictMessage, { duration: 5000, }); logger.warn('[OptimisticUpdate] Conflict detected', { error: error instanceof Error ? error.message : String(error), variables: typeof variables === 'object' ? variables : undefined, }); } // 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; /** Edge 6.2: Function to handle conflicts (409 errors) */ onConflict?: ( error: unknown, variables: TVariables, context: OptimisticContext | undefined, queryClient: QueryClient, ) => void; /** Edge 6.2: Whether to show conflict message to user (default: true) */ showConflictMessage?: boolean; } /** * 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, onConflict, showConflictMessage = true, } = 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, ) => { // Edge 6.2: Handle conflicts (409 errors) specially const isConflict = isConflictError(error); if (isConflict && onConflict) { // Use custom conflict handler if provided try { onConflict(error, variables, context, queryClient); } catch (conflictError) { logger.error('[OptimisticUpdate] Error in conflict handler', { error: conflictError instanceof Error ? conflictError.message : String(conflictError), }); // Fall back to default rollback on conflict handler error if (context?.previousValues) { for (const { queryKey, data } of context.previousValues) { queryClient.setQueryData(queryKey, data); } } } } else { // Default error handling: rollback optimistic update if (context?.previousValues) { for (const { queryKey, data } of context.previousValues) { queryClient.setQueryData(queryKey, data); } } // Edge 6.2: Show conflict message to user if (isConflict && showConflictMessage) { const conflictMessage = getConflictMessage(error); toast.error(conflictMessage, { duration: 5000, }); logger.warn('[OptimisticUpdate] Conflict detected', { error: error instanceof Error ? error.message : String(error), variables: typeof variables === 'object' ? variables : undefined, }); } } }, 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< TVariables = unknown, TData = unknown, > { /** 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; /** Edge 6.2: Function to handle conflicts (409 errors) */ onConflict?: ( error: unknown, variables: TVariables, context: OptimisticContext | undefined, queryClient: QueryClient, ) => void; /** Edge 6.2: Whether to show conflict message to user (default: true) */ showConflictMessage?: boolean; } /** * 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, onConflict, showConflictMessage = true, } = 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, ) => { // Edge 6.2: Handle conflicts (409 errors) specially const isConflict = isConflictError(error); if (isConflict && onConflict) { // Use custom conflict handler if provided try { onConflict(error, variables, context, queryClient); } catch (conflictError) { logger.error('[OptimisticUpdate] Error in conflict handler', { error: conflictError instanceof Error ? conflictError.message : String(conflictError), }); // Fall back to default rollback on conflict handler error if (context?.previousValues) { for (const { queryKey, data } of context.previousValues) { queryClient.setQueryData(queryKey, data); } } } } else { // Default error handling: rollback optimistic update if (context?.previousValues) { for (const { queryKey, data } of context.previousValues) { queryClient.setQueryData(queryKey, data); } } // Edge 6.2: Show conflict message to user if (isConflict && showConflictMessage) { const conflictMessage = getConflictMessage(error); toast.error(conflictMessage, { duration: 5000, }); logger.warn('[OptimisticUpdate] Conflict detected', { error: error instanceof Error ? error.message : String(error), variables: typeof variables === 'object' ? variables : undefined, }); } } }, onSettled: () => { for (const queryKey of queryKeys) { queryClient.invalidateQueries({ queryKey }); } }, }; }