374 lines
11 KiB
TypeScript
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 });
|
|
}
|
|
},
|
|
};
|
|
}
|
|
|