From 30edda176223d2be009d7ce5b4def951c468c656 Mon Sep 17 00:00:00 2001 From: chamikaJ Date: Fri, 27 Jun 2025 15:17:29 +0530 Subject: [PATCH] feat(task-management): enhance real-time updates and performance optimizations - Implemented CSS styles to prevent flickering during socket updates, ensuring stable content visibility. - Modified `TaskRow` component to improve loading behavior and prevent blank content during real-time updates. - Enhanced socket handlers to update task management state immediately upon receiving real-time data, reducing the need for unnecessary refetches. - Introduced logic to track loading state, ensuring consistent rendering and improved user experience during task updates. --- .../task-management/task-row-optimized.css | 16 +++++ .../components/task-management/task-row.tsx | 61 +++++++++++++--- .../src/hooks/useTaskSocketHandlers.ts | 70 ++++++++++++++++--- 3 files changed, 128 insertions(+), 19 deletions(-) diff --git a/worklenz-frontend/src/components/task-management/task-row-optimized.css b/worklenz-frontend/src/components/task-management/task-row-optimized.css index 57e811d4..95f28428 100644 --- a/worklenz-frontend/src/components/task-management/task-row-optimized.css +++ b/worklenz-frontend/src/components/task-management/task-row-optimized.css @@ -36,6 +36,22 @@ will-change: transform; } +/* REAL-TIME UPDATES: Prevent flickering during socket updates */ +.task-row-optimized.stable-content { + contain: layout style; + will-change: transform; + /* Prevent content from disappearing during real-time updates */ + min-height: 40px; + transition: none; /* Disable transitions during real-time updates */ +} + +.task-row-optimized.stable-content * { + contain: layout; + will-change: auto; + /* Ensure content stays visible */ + opacity: 1 !important; +} + /* Optimize initial render performance */ .task-row-optimized.initial-load * { contain: layout; diff --git a/worklenz-frontend/src/components/task-management/task-row.tsx b/worklenz-frontend/src/components/task-management/task-row.tsx index 5eb5a53a..c7184d88 100644 --- a/worklenz-frontend/src/components/task-management/task-row.tsx +++ b/worklenz-frontend/src/components/task-management/task-row.tsx @@ -179,9 +179,11 @@ const TaskRow: React.FC = React.memo(({ scrollableColumns, }) => { // PERFORMANCE OPTIMIZATION: Implement progressive loading - const [isFullyLoaded, setIsFullyLoaded] = useState(false); + // Immediately load first few tasks to prevent blank content for visible items + const [isFullyLoaded, setIsFullyLoaded] = useState((index !== undefined && index < 10) || false); const [isIntersecting, setIsIntersecting] = useState(false); const rowRef = useRef(null); + const hasBeenFullyLoadedOnce = useRef((index !== undefined && index < 10) || false); // Track if we've ever been fully loaded // PERFORMANCE OPTIMIZATION: Only connect to socket after component is visible const { socket, connected } = useSocket(); @@ -200,16 +202,18 @@ const TaskRow: React.FC = React.memo(({ // PERFORMANCE OPTIMIZATION: Intersection Observer for lazy loading useEffect(() => { - if (!rowRef.current) return; + // Skip intersection observer if already fully loaded + if (!rowRef.current || hasBeenFullyLoadedOnce.current) return; const observer = new IntersectionObserver( (entries) => { const [entry] = entries; - if (entry.isIntersecting && !isIntersecting) { + if (entry.isIntersecting && !isIntersecting && !hasBeenFullyLoadedOnce.current) { setIsIntersecting(true); // Delay full loading slightly to prioritize visible content const timeoutId = setTimeout(() => { setIsFullyLoaded(true); + hasBeenFullyLoadedOnce.current = true; // Mark as fully loaded once }, 50); return () => clearTimeout(timeoutId); @@ -227,10 +231,18 @@ const TaskRow: React.FC = React.memo(({ return () => { observer.disconnect(); }; - }, [isIntersecting]); + }, [isIntersecting, hasBeenFullyLoadedOnce.current]); // PERFORMANCE OPTIMIZATION: Skip expensive operations during initial render - const shouldRenderFull = isFullyLoaded || isDragOverlay || editTaskName; + // Once fully loaded, always render full to prevent blanking during real-time updates + const shouldRenderFull = isFullyLoaded || hasBeenFullyLoadedOnce.current || isDragOverlay || editTaskName; + + // REAL-TIME UPDATES: Ensure content stays loaded during socket updates + useEffect(() => { + if (shouldRenderFull && !hasBeenFullyLoadedOnce.current) { + hasBeenFullyLoadedOnce.current = true; + } + }, [shouldRenderFull]); // Optimized drag and drop setup with better performance const { @@ -355,7 +367,8 @@ const TaskRow: React.FC = React.memo(({ if (!task.id) return; dispatch(setSelectedTaskId(task.id)); dispatch(setShowTaskDrawer(true)); - // Fetch task data + // Fetch task data - this is necessary for detailed task drawer information + // that's not available in the list view (comments, attachments, etc.) dispatch(fetchTask({ taskId: task.id, projectId })); }, [task.id, projectId, dispatch]); @@ -898,7 +911,7 @@ const TaskRow: React.FC = React.memo(({ rowRef.current = node; }} style={dragStyle} - className={`${styleClasses.container} task-row-optimized ${shouldRenderFull ? 'fully-loaded' : 'initial-load'}`} + className={`${styleClasses.container} task-row-optimized ${shouldRenderFull ? 'fully-loaded' : 'initial-load'} ${hasBeenFullyLoadedOnce.current ? 'stable-content' : ''}`} data-task-id={task.id} >
@@ -1039,6 +1052,9 @@ const TaskRow: React.FC = React.memo(({ if (prevProps.isDragOverlay !== nextProps.isDragOverlay) return false; if (prevProps.groupId !== nextProps.groupId) return false; + // REAL-TIME UPDATES: Always re-render if updatedAt changed (indicates real-time update) + if (prevProps.task.updatedAt !== nextProps.task.updatedAt) return false; + // Deep comparison for task properties that commonly change const taskProps = ['title', 'progress', 'status', 'priority', 'description', 'startDate', 'dueDate']; for (const prop of taskProps) { @@ -1047,9 +1063,36 @@ const TaskRow: React.FC = React.memo(({ } } - // Compare arrays by length first (fast path) - if (prevProps.task.labels?.length !== nextProps.task.labels?.length) return false; + // REAL-TIME UPDATES: Compare assignees and labels content (not just length) + if (prevProps.task.assignees?.length !== nextProps.task.assignees?.length) return false; + if (prevProps.task.assignees?.length > 0) { + // Deep compare assignee IDs + const prevAssigneeIds = prevProps.task.assignees.sort(); + const nextAssigneeIds = nextProps.task.assignees.sort(); + for (let i = 0; i < prevAssigneeIds.length; i++) { + if (prevAssigneeIds[i] !== nextAssigneeIds[i]) return false; + } + } + if (prevProps.task.assignee_names?.length !== nextProps.task.assignee_names?.length) return false; + if (prevProps.task.assignee_names && nextProps.task.assignee_names && prevProps.task.assignee_names.length > 0) { + // Deep compare assignee names + for (let i = 0; i < prevProps.task.assignee_names.length; i++) { + if (prevProps.task.assignee_names[i] !== nextProps.task.assignee_names[i]) return false; + } + } + + if (prevProps.task.labels?.length !== nextProps.task.labels?.length) return false; + if (prevProps.task.labels?.length > 0) { + // Deep compare label IDs and names + for (let i = 0; i < prevProps.task.labels.length; i++) { + const prevLabel = prevProps.task.labels[i]; + const nextLabel = nextProps.task.labels[i]; + if (prevLabel.id !== nextLabel.id || prevLabel.name !== nextLabel.name || prevLabel.color !== nextLabel.color) { + return false; + } + } + } // Compare column configurations if (prevProps.fixedColumns?.length !== nextProps.fixedColumns?.length) return false; diff --git a/worklenz-frontend/src/hooks/useTaskSocketHandlers.ts b/worklenz-frontend/src/hooks/useTaskSocketHandlers.ts index 160fba41..3a0c0807 100644 --- a/worklenz-frontend/src/hooks/useTaskSocketHandlers.ts +++ b/worklenz-frontend/src/hooks/useTaskSocketHandlers.ts @@ -74,6 +74,19 @@ export const useTaskSocketHandlers = () => { selected: true, })) || []; + // REAL-TIME UPDATES: Update the task-management slice for immediate UI updates + if (data.id) { + dispatch(updateTask({ + id: data.id, + changes: { + assignees: data.assignees?.map(a => a.team_member_id) || [], + assignee_names: data.names || [], + updatedAt: new Date().toISOString(), + } + })); + } + + // Update the old task slice (for backward compatibility) const groupId = taskGroups?.find((group: ITaskListGroup) => group.tasks?.some( (task: IProjectTask) => @@ -98,9 +111,10 @@ export const useTaskSocketHandlers = () => { } as IProjectTask) ); - if (currentSession?.team_id && !loadingAssignees) { - dispatch(fetchTaskAssignees(currentSession.team_id)); - } + // Remove unnecessary refetch - real-time updates handle this + // if (currentSession?.team_id && !loadingAssignees) { + // dispatch(fetchTaskAssignees(currentSession.team_id)); + // } } }, [taskGroups, dispatch, currentSession?.team_id, loadingAssignees] @@ -110,11 +124,31 @@ export const useTaskSocketHandlers = () => { async (labels: ILabelsChangeResponse) => { if (!labels) return; + // REAL-TIME UPDATES: Update the task-management slice for immediate UI updates + if (labels.id) { + dispatch(updateTask({ + id: labels.id, + changes: { + labels: labels.labels?.map(l => ({ + id: l.id || '', + name: l.name || '', + color: l.color_code || '#1890ff', + end: l.end, + names: l.names + })) || [], + updatedAt: new Date().toISOString(), + } + })); + } + + // Update the old task slice and other related slices (for backward compatibility) + // Only update existing data, don't refetch from server await Promise.all([ dispatch(updateTaskLabel(labels)), dispatch(setTaskLabels(labels)), - dispatch(fetchLabels()), - projectId && dispatch(fetchLabelsByProject(projectId)), + // Remove unnecessary refetches - real-time updates handle this + // dispatch(fetchLabels()), + // projectId && dispatch(fetchLabelsByProject(projectId)), ]); }, [dispatch, projectId] @@ -189,11 +223,12 @@ export const useTaskSocketHandlers = () => { } })); } else if (!currentGroup || !targetGroup) { - // Fallback to refetch if groups not found (shouldn't happen normally) - console.log('🔄 Groups not found, refetching tasks...'); - if (projectId) { - dispatch(fetchTasksV3(projectId)); - } + // Log the issue but don't refetch - real-time updates should handle this + console.log('🔄 Groups not found, but avoiding refetch to prevent data thrashing'); + // Remove unnecessary refetch that causes data thrashing + // if (projectId) { + // dispatch(fetchTasksV3(projectId)); + // } } } }, @@ -611,13 +646,27 @@ export const useTaskSocketHandlers = () => { [dispatch, taskGroups] ); + // Handler for TASK_ASSIGNEES_CHANGE (fallback event with limited data) + const handleTaskAssigneesChange = useCallback( + (data: { assigneeIds: string[] }) => { + if (!data || !data.assigneeIds) return; + + // This event only provides assignee IDs, so we update what we can + // The full assignee data will come from QUICK_ASSIGNEES_UPDATE + console.log('🔄 Task assignees change (limited data):', data); + }, + [] + ); + // Register socket event listeners useEffect(() => { if (!socket) return; const eventHandlers = [ { event: SocketEvents.QUICK_ASSIGNEES_UPDATE.toString(), handler: handleAssigneesUpdate }, + { event: SocketEvents.TASK_ASSIGNEES_CHANGE.toString(), handler: handleTaskAssigneesChange }, { event: SocketEvents.TASK_LABELS_CHANGE.toString(), handler: handleLabelsChange }, + { event: SocketEvents.CREATE_LABEL.toString(), handler: handleLabelsChange }, { event: SocketEvents.TASK_STATUS_CHANGE.toString(), handler: handleTaskStatusChange }, { event: SocketEvents.TASK_PROGRESS_UPDATED.toString(), handler: handleTaskProgress }, { event: SocketEvents.TASK_PRIORITY_CHANGE.toString(), handler: handlePriorityChange }, @@ -646,6 +695,7 @@ export const useTaskSocketHandlers = () => { }, [ socket, handleAssigneesUpdate, + handleTaskAssigneesChange, handleLabelsChange, handleTaskStatusChange, handleTaskProgress,