veza/apps/web/src/utils/optimisticUpdates.ts
senke cabe64d365 edge-cases: implement Edge 3.1 - handle concurrent state updates
- Enhanced optimistic update utilities with concurrent update handling documentation
- cancelQueries in onMutate prevents refetches from overwriting optimistic updates
- setQueryData is atomic, so optimistic updates are safe even with concurrent mutations
- Added comments explaining how React Query handles concurrent mutations
- React Query automatically queues mutations, cancelQueries provides additional safety
- For critical resources, developers can use mutateAsync and await for sequential execution
- Prevents race conditions in concurrent state updates
2026-01-16 14:38:13 +01:00

682 lines
21 KiB
TypeScript

/**
* Optimistic Updates Utility
* FE-API-018: Utilities for optimistic UI updates
* Edge 6.2: Enhanced with conflict handling for data conflicts
* Edge 3.1: Enhanced with concurrent update handling
*
* Provides helpers for implementing optimistic updates in React components
* with automatic rollback on error and conflict resolution
*
* 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
*/
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<TData = unknown, TVariables = unknown>(
options: OptimisticUpdateOptions<TData, TVariables> & {
queryClient: QueryClient;
},
) {
const {
queryClient,
queryKeys,
optimisticData,
updateQueryData,
rollback,
onConflict,
showConflictMessage = true,
} = options;
return {
onMutate: async (variables: TVariables): Promise<OptimisticContext> => {
// Edge 3.1: Handle concurrent state updates
// Cancel outgoing refetches to avoid overwriting optimistic update
// This prevents race conditions when multiple mutations happen simultaneously
// Snapshot previous values for rollback
const previousValues: Array<{
queryKey: (string | number)[];
data: unknown;
}> = [];
// Cancel queries and snapshot previous values
// Edge 3.1: cancelQueries ensures that any in-flight refetches are cancelled,
// preventing them from overwriting our optimistic update
for (const queryKey of queryKeys) {
await queryClient.cancelQueries({ queryKey });
const previousData = queryClient.getQueryData(queryKey);
previousValues.push({ queryKey, data: previousData });
// 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)
if (updateQueryData) {
queryClient.setQueryData<TData>(queryKey, (old) =>
updateQueryData(old, variables, queryClient),
);
} else {
// Default: replace with optimistic data
queryClient.setQueryData<TData>(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<string, unknown>).items)
) {
return (data as Record<string, unknown>).items as unknown[];
}
// Check for tracks property common in this app
if (
'tracks' in data &&
Array.isArray((data as Record<string, unknown>).tracks)
) {
return (data as Record<string, unknown>).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<TItem, TVariables, TData>) {
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<OptimisticContext> => {
const previousValues: Array<{
queryKey: (string | number)[];
data: unknown;
}> = [];
// Edge 3.1: Cancel queries and snapshot to prevent concurrent update conflicts
for (const queryKey of queryKeys) {
await queryClient.cancelQueries({ queryKey });
const previousData = queryClient.getQueryData(queryKey);
previousValues.push({ queryKey, data: previousData });
}
// Edge 3.1: Apply optimistic update to array atomically
// setQueryData is atomic, preventing race conditions in concurrent updates
for (const queryKey of queryKeys) {
queryClient.setQueryData<TData>(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<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;
});
}
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<TVariables, TData>) {
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<OptimisticContext> => {
const previousValues: Array<{
queryKey: (string | number)[];
data: unknown;
}> = [];
// Edge 3.1: Cancel queries and snapshot to prevent concurrent update conflicts
for (const queryKey of queryKeys) {
await queryClient.cancelQueries({ queryKey });
const previousData = queryClient.getQueryData(queryKey);
previousValues.push({ queryKey, data: previousData });
// Edge 3.1: Apply optimistic update atomically
// setQueryData is atomic, preventing race conditions in concurrent updates
queryClient.setQueryData<TData>(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 });
}
},
};
}