2025-12-25 12:33:42 +00:00
|
|
|
/**
|
|
|
|
|
* Optimistic Updates Utility
|
|
|
|
|
* FE-API-018: Utilities for optimistic UI updates
|
2026-01-16 13:33:10 +00:00
|
|
|
* Edge 6.2: Enhanced with conflict handling for data conflicts
|
2026-01-16 13:38:13 +00:00
|
|
|
* Edge 3.1: Enhanced with concurrent update handling
|
2026-01-13 18:47:57 +00:00
|
|
|
*
|
2025-12-25 12:33:42 +00:00
|
|
|
* Provides helpers for implementing optimistic updates in React components
|
2026-01-16 13:33:10 +00:00
|
|
|
* with automatic rollback on error and conflict resolution
|
2026-01-16 13:38:13 +00:00
|
|
|
*
|
|
|
|
|
* Concurrent Update Handling:
|
|
|
|
|
* - React Query automatically queues mutations, but multiple mutations on the same
|
|
|
|
|
* resource can still run in parallel
|
|
|
|
|
* - `cancelQueries` in `onMutate` prevents refetches from overwriting optimistic updates
|
|
|
|
|
* - `setQueryData` is atomic, so optimistic updates are safe even if multiple mutations
|
|
|
|
|
* update the same query key simultaneously
|
|
|
|
|
* - Conflict handling (409 errors) ensures server-side conflicts are detected and handled
|
|
|
|
|
* - For critical resources, consider using `mutateAsync` and awaiting before triggering
|
|
|
|
|
* the next mutation to ensure sequential execution
|
2025-12-25 12:33:42 +00:00
|
|
|
*/
|
|
|
|
|
|
|
|
|
|
import { QueryClient } from '@tanstack/react-query';
|
2026-01-16 13:33:10 +00:00
|
|
|
import { AxiosError } from 'axios';
|
|
|
|
|
import { parseApiError } from './apiErrorHandler';
|
|
|
|
|
import { logger } from './logger';
|
|
|
|
|
import toast from './toast';
|
2025-12-25 12:33:42 +00:00
|
|
|
|
|
|
|
|
/**
|
|
|
|
|
* Options for optimistic update
|
|
|
|
|
*/
|
2026-01-07 18:39:21 +00:00
|
|
|
// Basic Generic Types
|
|
|
|
|
export interface OptimisticContext {
|
|
|
|
|
previousValues: Array<{ queryKey: (string | number)[]; data: unknown }>;
|
|
|
|
|
}
|
|
|
|
|
|
2026-01-16 13:33:10 +00:00
|
|
|
/**
|
|
|
|
|
* 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;
|
2026-01-16 13:33:56 +00:00
|
|
|
// Handle both number and string codes (code can be number or string)
|
|
|
|
|
if (code === 409) {
|
|
|
|
|
return true;
|
2026-01-16 13:33:10 +00:00
|
|
|
}
|
2026-01-16 13:33:56 +00:00
|
|
|
// Handle string codes
|
|
|
|
|
if (typeof code === 'string' && code === '409') {
|
|
|
|
|
return true;
|
2026-01-16 13:33:10 +00:00
|
|
|
}
|
2026-01-16 13:33:56 +00:00
|
|
|
// Fallback: convert to string and compare
|
2026-01-16 13:33:10 +00:00
|
|
|
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.';
|
|
|
|
|
}
|
|
|
|
|
|
2026-01-07 18:39:21 +00:00
|
|
|
/**
|
|
|
|
|
* Options for optimistic update
|
|
|
|
|
*/
|
2026-01-13 18:47:57 +00:00
|
|
|
export interface OptimisticUpdateOptions<
|
|
|
|
|
TData = unknown,
|
|
|
|
|
TVariables = unknown,
|
|
|
|
|
> {
|
2025-12-25 12:33:42 +00:00
|
|
|
/** 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?: (
|
2026-01-07 18:39:21 +00:00
|
|
|
oldData: TData | undefined,
|
2025-12-25 12:33:42 +00:00
|
|
|
variables: TVariables,
|
|
|
|
|
queryClient: QueryClient,
|
2026-01-07 18:39:21 +00:00
|
|
|
) => TData;
|
2025-12-25 12:33:42 +00:00
|
|
|
/** Function to rollback on error */
|
|
|
|
|
rollback?: (variables: TVariables, queryClient: QueryClient) => void;
|
2026-01-16 13:33:10 +00:00
|
|
|
/** 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;
|
2025-12-25 12:33:42 +00:00
|
|
|
}
|
|
|
|
|
|
|
|
|
|
/**
|
|
|
|
|
* Creates an optimistic update configuration for useMutation
|
2026-01-13 18:47:57 +00:00
|
|
|
*
|
2025-12-25 12:33:42 +00:00
|
|
|
* @example
|
|
|
|
|
* ```typescript
|
|
|
|
|
* const queryClient = useQueryClient();
|
|
|
|
|
* const mutation = useMutation({
|
|
|
|
|
* mutationFn: updatePlaylist,
|
|
|
|
|
* ...createOptimisticUpdate({
|
|
|
|
|
* queryClient,
|
|
|
|
|
* queryKeys: [['playlist', playlistId], ['playlists']],
|
|
|
|
|
* optimisticData: (variables) => ({
|
|
|
|
|
* ...currentPlaylist,
|
|
|
|
|
* ...variables,
|
|
|
|
|
* }),
|
|
|
|
|
* }),
|
|
|
|
|
* });
|
|
|
|
|
* ```
|
|
|
|
|
*/
|
2026-01-07 18:39:21 +00:00
|
|
|
export function createOptimisticUpdate<TData = unknown, TVariables = unknown>(
|
2026-01-13 18:47:57 +00:00
|
|
|
options: OptimisticUpdateOptions<TData, TVariables> & {
|
|
|
|
|
queryClient: QueryClient;
|
|
|
|
|
},
|
2025-12-25 12:33:42 +00:00
|
|
|
) {
|
2026-01-16 13:33:10 +00:00
|
|
|
const {
|
|
|
|
|
queryClient,
|
|
|
|
|
queryKeys,
|
|
|
|
|
optimisticData,
|
|
|
|
|
updateQueryData,
|
|
|
|
|
rollback,
|
|
|
|
|
onConflict,
|
|
|
|
|
showConflictMessage = true,
|
|
|
|
|
} = options;
|
2025-12-25 12:33:42 +00:00
|
|
|
|
|
|
|
|
return {
|
2026-01-07 18:39:21 +00:00
|
|
|
onMutate: async (variables: TVariables): Promise<OptimisticContext> => {
|
2026-01-16 13:38:13 +00:00
|
|
|
// Edge 3.1: Handle concurrent state updates
|
2025-12-25 12:33:42 +00:00
|
|
|
// Cancel outgoing refetches to avoid overwriting optimistic update
|
2026-01-16 13:38:13 +00:00
|
|
|
// This prevents race conditions when multiple mutations happen simultaneously
|
2025-12-25 12:33:42 +00:00
|
|
|
// Snapshot previous values for rollback
|
2026-01-13 18:47:57 +00:00
|
|
|
const previousValues: Array<{
|
|
|
|
|
queryKey: (string | number)[];
|
|
|
|
|
data: unknown;
|
|
|
|
|
}> = [];
|
2025-12-25 12:33:42 +00:00
|
|
|
|
|
|
|
|
// Cancel queries and snapshot previous values
|
2026-01-16 13:38:13 +00:00
|
|
|
// Edge 3.1: cancelQueries ensures that any in-flight refetches are cancelled,
|
|
|
|
|
// preventing them from overwriting our optimistic update
|
2025-12-25 12:33:42 +00:00
|
|
|
for (const queryKey of queryKeys) {
|
|
|
|
|
await queryClient.cancelQueries({ queryKey });
|
2026-01-07 18:39:21 +00:00
|
|
|
|
2025-12-25 12:33:42 +00:00
|
|
|
const previousData = queryClient.getQueryData(queryKey);
|
|
|
|
|
previousValues.push({ queryKey, data: previousData });
|
|
|
|
|
|
2026-01-16 13:38:13 +00:00
|
|
|
// Edge 3.1: Apply optimistic update atomically
|
|
|
|
|
// setQueryData is atomic, so even if multiple mutations update the same
|
|
|
|
|
// query key simultaneously, the last one wins (which is correct behavior)
|
2025-12-25 12:33:42 +00:00
|
|
|
if (updateQueryData) {
|
2026-01-07 18:39:21 +00:00
|
|
|
queryClient.setQueryData<TData>(queryKey, (old) =>
|
2025-12-25 12:33:42 +00:00
|
|
|
updateQueryData(old, variables, queryClient),
|
|
|
|
|
);
|
|
|
|
|
} else {
|
|
|
|
|
// Default: replace with optimistic data
|
2026-01-07 18:39:21 +00:00
|
|
|
queryClient.setQueryData<TData>(queryKey, optimisticData(variables));
|
2025-12-25 12:33:42 +00:00
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// Return context with previous values for rollback
|
|
|
|
|
return { previousValues };
|
|
|
|
|
},
|
2026-01-13 18:47:57 +00:00
|
|
|
onError: (
|
2026-01-16 13:33:10 +00:00
|
|
|
error: Error,
|
|
|
|
|
variables: TVariables,
|
2026-01-13 18:47:57 +00:00
|
|
|
context: OptimisticContext | undefined,
|
|
|
|
|
) => {
|
2026-01-16 13:33:10 +00:00
|
|
|
// 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);
|
|
|
|
|
}
|
2025-12-25 12:33:42 +00:00
|
|
|
}
|
|
|
|
|
|
2026-01-16 13:33:10 +00:00
|
|
|
// 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);
|
|
|
|
|
}
|
2025-12-25 12:33:42 +00:00
|
|
|
}
|
|
|
|
|
},
|
|
|
|
|
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)
|
|
|
|
|
*/
|
2026-01-07 18:39:21 +00:00
|
|
|
/**
|
|
|
|
|
* Helper for optimistic updates with array operations (add, remove, update)
|
|
|
|
|
*/
|
2026-01-13 18:47:57 +00:00
|
|
|
export interface ArrayOptimisticUpdateOptions<
|
|
|
|
|
TItem = unknown,
|
|
|
|
|
TVariables = unknown,
|
|
|
|
|
TData = unknown,
|
|
|
|
|
> {
|
2025-12-25 12:33:42 +00:00
|
|
|
/** 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 */
|
2026-01-07 18:39:21 +00:00
|
|
|
getArray?: (data: TData) => TItem[];
|
2025-12-25 12:33:42 +00:00
|
|
|
/** 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;
|
2026-01-16 13:33:10 +00:00
|
|
|
/** 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;
|
2025-12-25 12:33:42 +00:00
|
|
|
}
|
|
|
|
|
|
|
|
|
|
/**
|
|
|
|
|
* Creates optimistic update for array operations (add, remove, update)
|
2026-01-13 18:47:57 +00:00
|
|
|
*
|
2025-12-25 12:33:42 +00:00
|
|
|
* @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,
|
|
|
|
|
* }),
|
|
|
|
|
* }),
|
|
|
|
|
* });
|
2026-01-07 18:39:21 +00:00
|
|
|
* // ...
|
|
|
|
|
* ```
|
|
|
|
|
*/
|
|
|
|
|
/**
|
|
|
|
|
* 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') {
|
2026-01-13 18:47:57 +00:00
|
|
|
if (
|
|
|
|
|
'items' in data &&
|
|
|
|
|
Array.isArray((data as Record<string, unknown>).items)
|
|
|
|
|
) {
|
2026-01-07 18:39:21 +00:00
|
|
|
return (data as Record<string, unknown>).items as unknown[];
|
|
|
|
|
}
|
|
|
|
|
// Check for tracks property common in this app
|
2026-01-13 18:47:57 +00:00
|
|
|
if (
|
|
|
|
|
'tracks' in data &&
|
|
|
|
|
Array.isArray((data as Record<string, unknown>).tracks)
|
|
|
|
|
) {
|
2026-01-07 18:39:21 +00:00
|
|
|
return (data as Record<string, unknown>).tracks as unknown[];
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
return [];
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
/**
|
|
|
|
|
* Creates optimistic update for array operations (add, remove, update)
|
2026-01-13 18:47:57 +00:00
|
|
|
*
|
2026-01-07 18:39:21 +00:00
|
|
|
* @example
|
|
|
|
|
* ```typescript
|
|
|
|
|
* // Add item
|
|
|
|
|
* const addMutation = useMutation({
|
|
|
|
|
* mutationFn: addTrackToPlaylist,
|
2025-12-25 12:33:42 +00:00
|
|
|
* ...createArrayOptimisticUpdate({
|
|
|
|
|
* queryKeys: [['playlist', playlistId]],
|
|
|
|
|
* arrayQueryKey: ['playlist', playlistId, 'tracks'],
|
2026-01-07 18:39:21 +00:00
|
|
|
* operation: 'add',
|
|
|
|
|
* optimisticItem: (variables) => ({
|
|
|
|
|
* id: `temp-${Date.now()}`,
|
|
|
|
|
* track_id: variables.trackId,
|
|
|
|
|
* ...variables,
|
|
|
|
|
* }),
|
2025-12-25 12:33:42 +00:00
|
|
|
* }),
|
|
|
|
|
* });
|
2026-01-07 18:39:21 +00:00
|
|
|
* // ...
|
2025-12-25 12:33:42 +00:00
|
|
|
* ```
|
|
|
|
|
*/
|
2026-01-13 18:47:57 +00:00
|
|
|
export function createArrayOptimisticUpdate<
|
|
|
|
|
TItem = unknown,
|
|
|
|
|
TVariables = unknown,
|
|
|
|
|
TData = unknown,
|
|
|
|
|
>(options: ArrayOptimisticUpdateOptions<TItem, TVariables, TData>) {
|
2025-12-25 12:33:42 +00:00
|
|
|
const {
|
|
|
|
|
queryClient,
|
|
|
|
|
queryKeys,
|
|
|
|
|
arrayQueryKey,
|
2026-01-07 18:39:21 +00:00
|
|
|
getArray = (data: unknown) => safeGetArray(data) as TItem[],
|
2025-12-25 12:33:42 +00:00
|
|
|
operation,
|
|
|
|
|
optimisticItem,
|
|
|
|
|
matchItem,
|
|
|
|
|
updateItem,
|
2026-01-16 13:33:10 +00:00
|
|
|
onConflict,
|
|
|
|
|
showConflictMessage = true,
|
2025-12-25 12:33:42 +00:00
|
|
|
} = options;
|
|
|
|
|
|
|
|
|
|
return {
|
2026-01-07 18:39:21 +00:00
|
|
|
onMutate: async (variables: TVariables): Promise<OptimisticContext> => {
|
2026-01-13 18:47:57 +00:00
|
|
|
const previousValues: Array<{
|
|
|
|
|
queryKey: (string | number)[];
|
|
|
|
|
data: unknown;
|
|
|
|
|
}> = [];
|
2025-12-25 12:33:42 +00:00
|
|
|
|
2026-01-16 13:38:13 +00:00
|
|
|
// Edge 3.1: Cancel queries and snapshot to prevent concurrent update conflicts
|
2025-12-25 12:33:42 +00:00
|
|
|
for (const queryKey of queryKeys) {
|
|
|
|
|
await queryClient.cancelQueries({ queryKey });
|
|
|
|
|
const previousData = queryClient.getQueryData(queryKey);
|
|
|
|
|
previousValues.push({ queryKey, data: previousData });
|
|
|
|
|
}
|
|
|
|
|
|
2026-01-16 13:38:13 +00:00
|
|
|
// Edge 3.1: Apply optimistic update to array atomically
|
|
|
|
|
// setQueryData is atomic, preventing race conditions in concurrent updates
|
2025-12-25 12:33:42 +00:00
|
|
|
for (const queryKey of queryKeys) {
|
2026-01-07 18:39:21 +00:00
|
|
|
queryClient.setQueryData<TData>(queryKey, (old) => {
|
2025-12-25 12:33:42 +00:00
|
|
|
if (!old) return old;
|
|
|
|
|
|
2026-01-07 18:39:21 +00:00
|
|
|
// We treat old data as unknown initially
|
|
|
|
|
const oldData = old as unknown;
|
|
|
|
|
const array = getArray(oldData as TData);
|
2025-12-25 12:33:42 +00:00
|
|
|
let newArray: TItem[];
|
|
|
|
|
|
|
|
|
|
switch (operation) {
|
|
|
|
|
case 'add':
|
|
|
|
|
if (optimisticItem) {
|
|
|
|
|
newArray = [...array, optimisticItem(variables)];
|
|
|
|
|
} else {
|
|
|
|
|
newArray = array;
|
|
|
|
|
}
|
|
|
|
|
break;
|
|
|
|
|
|
|
|
|
|
case 'remove':
|
|
|
|
|
if (matchItem) {
|
2026-01-13 18:47:57 +00:00
|
|
|
newArray = array.filter(
|
|
|
|
|
(item: TItem) => !matchItem(item, variables),
|
|
|
|
|
);
|
2025-12-25 12:33:42 +00:00
|
|
|
} else {
|
|
|
|
|
newArray = array;
|
|
|
|
|
}
|
|
|
|
|
break;
|
|
|
|
|
|
|
|
|
|
case 'update':
|
|
|
|
|
if (matchItem && updateItem) {
|
2025-12-28 15:07:02 +00:00
|
|
|
newArray = array.map((item: TItem) =>
|
2026-01-13 18:47:57 +00:00
|
|
|
matchItem(item, variables)
|
|
|
|
|
? updateItem(item, variables)
|
|
|
|
|
: item,
|
2025-12-25 12:33:42 +00:00
|
|
|
);
|
|
|
|
|
} else {
|
|
|
|
|
newArray = array;
|
|
|
|
|
}
|
|
|
|
|
break;
|
|
|
|
|
|
|
|
|
|
default:
|
|
|
|
|
newArray = array;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// Update the array in the data structure
|
2026-02-12 21:21:55 +00:00
|
|
|
// Generic TData requires structural cast -- array manipulation preserves shape
|
2026-01-07 18:39:21 +00:00
|
|
|
if (Array.isArray(oldData)) {
|
|
|
|
|
return newArray as unknown as TData;
|
2025-12-25 12:33:42 +00:00
|
|
|
}
|
2026-01-07 18:39:21 +00:00
|
|
|
|
|
|
|
|
if (oldData && typeof oldData === 'object') {
|
|
|
|
|
const oldObj = oldData as Record<string, unknown>;
|
|
|
|
|
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;
|
2025-12-25 12:33:42 +00:00
|
|
|
});
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
return { previousValues };
|
|
|
|
|
},
|
2026-01-13 18:47:57 +00:00
|
|
|
onError: (
|
2026-01-16 13:33:10 +00:00
|
|
|
error: Error,
|
|
|
|
|
variables: TVariables,
|
2026-01-13 18:47:57 +00:00
|
|
|
context: OptimisticContext | undefined,
|
|
|
|
|
) => {
|
2026-01-16 13:33:10 +00:00
|
|
|
// 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,
|
|
|
|
|
});
|
2025-12-25 12:33:42 +00:00
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
},
|
|
|
|
|
onSettled: () => {
|
|
|
|
|
for (const queryKey of queryKeys) {
|
|
|
|
|
queryClient.invalidateQueries({ queryKey });
|
|
|
|
|
}
|
|
|
|
|
},
|
|
|
|
|
};
|
|
|
|
|
}
|
|
|
|
|
|
2026-01-07 18:39:21 +00:00
|
|
|
/**
|
|
|
|
|
* 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;
|
|
|
|
|
}
|
|
|
|
|
|
2025-12-25 12:33:42 +00:00
|
|
|
/**
|
|
|
|
|
* Helper for toggle operations (like/unlike, follow/unfollow)
|
|
|
|
|
*/
|
2026-01-16 13:33:10 +00:00
|
|
|
export interface ToggleOptimisticUpdateOptions<
|
|
|
|
|
TVariables = unknown,
|
|
|
|
|
TData = unknown,
|
|
|
|
|
> {
|
2025-12-25 12:33:42 +00:00
|
|
|
/** QueryClient instance */
|
|
|
|
|
queryClient: QueryClient;
|
|
|
|
|
/** Query keys to update */
|
|
|
|
|
queryKeys: (string | number)[][];
|
|
|
|
|
/** Current state value */
|
|
|
|
|
currentValue: boolean;
|
|
|
|
|
/** Function to get the value from query data */
|
2026-01-07 18:39:21 +00:00
|
|
|
getValue?: (data: TData) => boolean;
|
2025-12-25 12:33:42 +00:00
|
|
|
/** Function to get the count from query data */
|
2026-01-07 18:39:21 +00:00
|
|
|
getCount?: (data: TData) => number;
|
2025-12-25 12:33:42 +00:00
|
|
|
/** New state value (opposite of currentValue) */
|
|
|
|
|
newValue?: boolean;
|
|
|
|
|
/** Whether to update count */
|
|
|
|
|
updateCount?: boolean;
|
|
|
|
|
/** Count increment/decrement */
|
|
|
|
|
countDelta?: number;
|
2026-01-16 13:33:10 +00:00
|
|
|
/** 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;
|
2025-12-25 12:33:42 +00:00
|
|
|
}
|
|
|
|
|
|
|
|
|
|
/**
|
|
|
|
|
* Creates optimistic update for toggle operations
|
|
|
|
|
*/
|
2026-01-13 18:47:57 +00:00
|
|
|
export function createToggleOptimisticUpdate<
|
|
|
|
|
TVariables = unknown,
|
|
|
|
|
TData = unknown,
|
2026-01-16 13:33:10 +00:00
|
|
|
>(options: ToggleOptimisticUpdateOptions<TVariables, TData>) {
|
2025-12-25 12:33:42 +00:00
|
|
|
const {
|
|
|
|
|
queryClient,
|
|
|
|
|
queryKeys,
|
|
|
|
|
currentValue,
|
2026-01-07 18:39:21 +00:00
|
|
|
getValue: _getValue,
|
|
|
|
|
getCount: _getCount,
|
2025-12-25 12:33:42 +00:00
|
|
|
newValue = !currentValue,
|
|
|
|
|
updateCount = true,
|
|
|
|
|
countDelta = 1,
|
2026-01-16 13:33:10 +00:00
|
|
|
onConflict,
|
|
|
|
|
showConflictMessage = true,
|
2025-12-25 12:33:42 +00:00
|
|
|
} = options;
|
|
|
|
|
|
2026-01-07 18:39:21 +00:00
|
|
|
// 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.
|
|
|
|
|
|
2025-12-25 12:33:42 +00:00
|
|
|
return {
|
2026-01-07 18:39:21 +00:00
|
|
|
onMutate: async (_variables: TVariables): Promise<OptimisticContext> => {
|
2026-01-13 18:47:57 +00:00
|
|
|
const previousValues: Array<{
|
|
|
|
|
queryKey: (string | number)[];
|
|
|
|
|
data: unknown;
|
|
|
|
|
}> = [];
|
2025-12-25 12:33:42 +00:00
|
|
|
|
2026-01-16 13:38:13 +00:00
|
|
|
// Edge 3.1: Cancel queries and snapshot to prevent concurrent update conflicts
|
2025-12-25 12:33:42 +00:00
|
|
|
for (const queryKey of queryKeys) {
|
|
|
|
|
await queryClient.cancelQueries({ queryKey });
|
|
|
|
|
const previousData = queryClient.getQueryData(queryKey);
|
|
|
|
|
previousValues.push({ queryKey, data: previousData });
|
|
|
|
|
|
2026-01-16 13:38:13 +00:00
|
|
|
// Edge 3.1: Apply optimistic update atomically
|
|
|
|
|
// setQueryData is atomic, preventing race conditions in concurrent updates
|
2026-01-07 18:39:21 +00:00
|
|
|
queryClient.setQueryData<TData>(queryKey, (old) => {
|
2025-12-25 12:33:42 +00:00
|
|
|
if (!old) return old;
|
|
|
|
|
|
2026-02-12 21:21:55 +00:00
|
|
|
// Structural cast: TData is a superset of ToggleableEntity at runtime
|
2026-01-07 18:39:21 +00:00
|
|
|
const entity = old as unknown as ToggleableEntity;
|
|
|
|
|
const updated = { ...entity };
|
2025-12-25 12:33:42 +00:00
|
|
|
|
|
|
|
|
// 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) {
|
2026-01-13 18:47:57 +00:00
|
|
|
updated.like_count = Math.max(
|
|
|
|
|
0,
|
|
|
|
|
(updated.like_count || 0) + countDelta,
|
|
|
|
|
);
|
2025-12-25 12:33:42 +00:00
|
|
|
}
|
|
|
|
|
if ('likeCount' in updated) {
|
2026-01-13 18:47:57 +00:00
|
|
|
updated.likeCount = Math.max(
|
|
|
|
|
0,
|
|
|
|
|
(updated.likeCount || 0) + countDelta,
|
|
|
|
|
);
|
2025-12-25 12:33:42 +00:00
|
|
|
}
|
|
|
|
|
if ('follower_count' in updated) {
|
2026-01-13 18:47:57 +00:00
|
|
|
updated.follower_count = Math.max(
|
|
|
|
|
0,
|
|
|
|
|
(updated.follower_count || 0) + countDelta,
|
|
|
|
|
);
|
2025-12-25 12:33:42 +00:00
|
|
|
}
|
|
|
|
|
if ('followerCount' in updated) {
|
2026-01-13 18:47:57 +00:00
|
|
|
updated.followerCount = Math.max(
|
|
|
|
|
0,
|
|
|
|
|
(updated.followerCount || 0) + countDelta,
|
|
|
|
|
);
|
2025-12-25 12:33:42 +00:00
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
2026-01-07 18:39:21 +00:00
|
|
|
return updated as unknown as TData;
|
2025-12-25 12:33:42 +00:00
|
|
|
});
|
|
|
|
|
}
|
|
|
|
|
|
2026-01-07 18:39:21 +00:00
|
|
|
return { previousValues };
|
2025-12-25 12:33:42 +00:00
|
|
|
},
|
2026-01-13 18:47:57 +00:00
|
|
|
onError: (
|
2026-01-16 13:33:10 +00:00
|
|
|
error: Error,
|
|
|
|
|
variables: TVariables,
|
2026-01-13 18:47:57 +00:00
|
|
|
context: OptimisticContext | undefined,
|
|
|
|
|
) => {
|
2026-01-16 13:33:10 +00:00
|
|
|
// 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,
|
|
|
|
|
});
|
2025-12-25 12:33:42 +00:00
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
},
|
|
|
|
|
onSettled: () => {
|
|
|
|
|
for (const queryKey of queryKeys) {
|
|
|
|
|
queryClient.invalidateQueries({ queryKey });
|
|
|
|
|
}
|
|
|
|
|
},
|
|
|
|
|
};
|
|
|
|
|
}
|