veza/apps/web/src/utils/optimisticUpdates.ts

374 lines
11 KiB
TypeScript

/**
* 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
*/
export interface OptimisticUpdateOptions<TData = any, TVariables = any> {
/** 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: any,
variables: TVariables,
queryClient: QueryClient,
) => any;
/** 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<TData = any, TVariables = any>(
options: OptimisticUpdateOptions<TData, TVariables> & { queryClient: QueryClient },
) {
const {
queryClient,
queryKeys,
optimisticData,
updateQueryData,
rollback,
} = options;
return {
onMutate: async (variables: TVariables) => {
// Cancel outgoing refetches to avoid overwriting optimistic update
// Snapshot previous values for rollback
const previousValues: Array<{ queryKey: (string | number)[]; data: any }> = [];
// 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: any) =>
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: any, variables: TVariables, context: any) => {
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)
*/
export interface ArrayOptimisticUpdateOptions<TItem = any, TVariables = any> {
/** 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: any) => 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,
* }),
* }),
* });
*
* // Remove item
* const removeMutation = useMutation({
* mutationFn: removeTrackFromPlaylist,
* ...createArrayOptimisticUpdate({
* queryKeys: [['playlist', playlistId]],
* arrayQueryKey: ['playlist', playlistId, 'tracks'],
* operation: 'remove',
* matchItem: (item, variables) => item.id === variables.trackId,
* }),
* });
* ```
*/
export function createArrayOptimisticUpdate<TItem = any, TVariables = any>(
options: ArrayOptimisticUpdateOptions<TItem, TVariables>,
) {
const {
queryClient,
queryKeys,
arrayQueryKey,
getArray = (data: any) => (Array.isArray(data) ? data : data?.items || []),
operation,
optimisticItem,
matchItem,
updateItem,
} = options;
return {
onMutate: async (variables: TVariables) => {
const previousValues: Array<{ queryKey: (string | number)[]; data: any }> = [];
// 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: any) => {
if (!old) return old;
const array = getArray(old);
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) => !matchItem(item, variables));
} else {
newArray = array;
}
break;
case 'update':
if (matchItem && updateItem) {
newArray = array.map((item) =>
matchItem(item, variables) ? updateItem(item, variables) : item,
);
} else {
newArray = array;
}
break;
default:
newArray = array;
}
// Update the array in the data structure
if (Array.isArray(old)) {
return newArray;
} else if (old.items) {
return { ...old, items: newArray };
} else if (old.tracks) {
return { ...old, tracks: newArray };
} else {
return { ...old, [arrayQueryKey[arrayQueryKey.length - 1] as string]: newArray };
}
});
}
return { previousValues };
},
onError: (error: any, variables: TVariables, context: any) => {
if (context?.previousValues) {
for (const { queryKey, data } of context.previousValues) {
queryClient.setQueryData(queryKey, data);
}
}
},
onSettled: () => {
for (const queryKey of queryKeys) {
queryClient.invalidateQueries({ queryKey });
}
},
};
}
/**
* Helper for toggle operations (like/unlike, follow/unfollow)
*/
export interface ToggleOptimisticUpdateOptions<TVariables = any> {
/** 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: any) => boolean;
/** Function to get the count from query data */
getCount?: (data: any) => 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
*
* @example
* ```typescript
* const likeMutation = useMutation({
* mutationFn: () => likeTrack(trackId),
* ...createToggleOptimisticUpdate({
* queryKeys: [['track', trackId], ['tracks']],
* currentValue: isLiked,
* updateCount: true,
* countDelta: 1,
* }),
* });
* ```
*/
export function createToggleOptimisticUpdate<TVariables = any>(
options: ToggleOptimisticUpdateOptions<TVariables>,
) {
const {
queryClient,
queryKeys,
currentValue,
getValue = (data: any) => data?.is_liked || data?.isLiked || false,
getCount = (data: any) => data?.like_count || data?.likeCount || 0,
newValue = !currentValue,
updateCount = true,
countDelta = 1,
} = options;
return {
onMutate: async (variables: TVariables) => {
const previousValues: Array<{ queryKey: (string | number)[]; data: any }> = [];
for (const queryKey of queryKeys) {
await queryClient.cancelQueries({ queryKey });
const previousData = queryClient.getQueryData(queryKey);
previousValues.push({ queryKey, data: previousData });
queryClient.setQueryData(queryKey, (old: any) => {
if (!old) return old;
const updated = { ...old };
// 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;
});
}
return { previousValues, newValue };
},
onError: (error: any, variables: TVariables, context: any) => {
if (context?.previousValues) {
for (const { queryKey, data } of context.previousValues) {
queryClient.setQueryData(queryKey, data);
}
}
},
onSettled: () => {
for (const queryKey of queryKeys) {
queryClient.invalidateQueries({ queryKey });
}
},
};
}