Merge pull request #198 from Worklenz/fix/task-list-realtime-update
feat(task-management): enhance real-time updates and performance opti…
This commit is contained in:
@@ -36,6 +36,22 @@
|
|||||||
will-change: transform;
|
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 */
|
/* Optimize initial render performance */
|
||||||
.task-row-optimized.initial-load * {
|
.task-row-optimized.initial-load * {
|
||||||
contain: layout;
|
contain: layout;
|
||||||
|
|||||||
@@ -179,9 +179,11 @@ const TaskRow: React.FC<TaskRowProps> = React.memo(({
|
|||||||
scrollableColumns,
|
scrollableColumns,
|
||||||
}) => {
|
}) => {
|
||||||
// PERFORMANCE OPTIMIZATION: Implement progressive loading
|
// 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 [isIntersecting, setIsIntersecting] = useState(false);
|
||||||
const rowRef = useRef<HTMLDivElement>(null);
|
const rowRef = useRef<HTMLDivElement>(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
|
// PERFORMANCE OPTIMIZATION: Only connect to socket after component is visible
|
||||||
const { socket, connected } = useSocket();
|
const { socket, connected } = useSocket();
|
||||||
@@ -200,16 +202,18 @@ const TaskRow: React.FC<TaskRowProps> = React.memo(({
|
|||||||
|
|
||||||
// PERFORMANCE OPTIMIZATION: Intersection Observer for lazy loading
|
// PERFORMANCE OPTIMIZATION: Intersection Observer for lazy loading
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!rowRef.current) return;
|
// Skip intersection observer if already fully loaded
|
||||||
|
if (!rowRef.current || hasBeenFullyLoadedOnce.current) return;
|
||||||
|
|
||||||
const observer = new IntersectionObserver(
|
const observer = new IntersectionObserver(
|
||||||
(entries) => {
|
(entries) => {
|
||||||
const [entry] = entries;
|
const [entry] = entries;
|
||||||
if (entry.isIntersecting && !isIntersecting) {
|
if (entry.isIntersecting && !isIntersecting && !hasBeenFullyLoadedOnce.current) {
|
||||||
setIsIntersecting(true);
|
setIsIntersecting(true);
|
||||||
// Delay full loading slightly to prioritize visible content
|
// Delay full loading slightly to prioritize visible content
|
||||||
const timeoutId = setTimeout(() => {
|
const timeoutId = setTimeout(() => {
|
||||||
setIsFullyLoaded(true);
|
setIsFullyLoaded(true);
|
||||||
|
hasBeenFullyLoadedOnce.current = true; // Mark as fully loaded once
|
||||||
}, 50);
|
}, 50);
|
||||||
|
|
||||||
return () => clearTimeout(timeoutId);
|
return () => clearTimeout(timeoutId);
|
||||||
@@ -227,10 +231,18 @@ const TaskRow: React.FC<TaskRowProps> = React.memo(({
|
|||||||
return () => {
|
return () => {
|
||||||
observer.disconnect();
|
observer.disconnect();
|
||||||
};
|
};
|
||||||
}, [isIntersecting]);
|
}, [isIntersecting, hasBeenFullyLoadedOnce.current]);
|
||||||
|
|
||||||
// PERFORMANCE OPTIMIZATION: Skip expensive operations during initial render
|
// 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
|
// Optimized drag and drop setup with better performance
|
||||||
const {
|
const {
|
||||||
@@ -355,7 +367,8 @@ const TaskRow: React.FC<TaskRowProps> = React.memo(({
|
|||||||
if (!task.id) return;
|
if (!task.id) return;
|
||||||
dispatch(setSelectedTaskId(task.id));
|
dispatch(setSelectedTaskId(task.id));
|
||||||
dispatch(setShowTaskDrawer(true));
|
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 }));
|
dispatch(fetchTask({ taskId: task.id, projectId }));
|
||||||
}, [task.id, projectId, dispatch]);
|
}, [task.id, projectId, dispatch]);
|
||||||
|
|
||||||
@@ -898,7 +911,7 @@ const TaskRow: React.FC<TaskRowProps> = React.memo(({
|
|||||||
rowRef.current = node;
|
rowRef.current = node;
|
||||||
}}
|
}}
|
||||||
style={dragStyle}
|
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}
|
data-task-id={task.id}
|
||||||
>
|
>
|
||||||
<div className="flex h-10 max-h-10 overflow-visible relative">
|
<div className="flex h-10 max-h-10 overflow-visible relative">
|
||||||
@@ -1039,6 +1052,9 @@ const TaskRow: React.FC<TaskRowProps> = React.memo(({
|
|||||||
if (prevProps.isDragOverlay !== nextProps.isDragOverlay) return false;
|
if (prevProps.isDragOverlay !== nextProps.isDragOverlay) return false;
|
||||||
if (prevProps.groupId !== nextProps.groupId) 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
|
// Deep comparison for task properties that commonly change
|
||||||
const taskProps = ['title', 'progress', 'status', 'priority', 'description', 'startDate', 'dueDate'];
|
const taskProps = ['title', 'progress', 'status', 'priority', 'description', 'startDate', 'dueDate'];
|
||||||
for (const prop of taskProps) {
|
for (const prop of taskProps) {
|
||||||
@@ -1047,9 +1063,36 @@ const TaskRow: React.FC<TaskRowProps> = React.memo(({
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Compare arrays by length first (fast path)
|
// REAL-TIME UPDATES: Compare assignees and labels content (not just length)
|
||||||
if (prevProps.task.labels?.length !== nextProps.task.labels?.length) return false;
|
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?.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
|
// Compare column configurations
|
||||||
if (prevProps.fixedColumns?.length !== nextProps.fixedColumns?.length) return false;
|
if (prevProps.fixedColumns?.length !== nextProps.fixedColumns?.length) return false;
|
||||||
|
|||||||
@@ -74,6 +74,19 @@ export const useTaskSocketHandlers = () => {
|
|||||||
selected: true,
|
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) =>
|
const groupId = taskGroups?.find((group: ITaskListGroup) =>
|
||||||
group.tasks?.some(
|
group.tasks?.some(
|
||||||
(task: IProjectTask) =>
|
(task: IProjectTask) =>
|
||||||
@@ -98,9 +111,10 @@ export const useTaskSocketHandlers = () => {
|
|||||||
} as IProjectTask)
|
} as IProjectTask)
|
||||||
);
|
);
|
||||||
|
|
||||||
if (currentSession?.team_id && !loadingAssignees) {
|
// Remove unnecessary refetch - real-time updates handle this
|
||||||
dispatch(fetchTaskAssignees(currentSession.team_id));
|
// if (currentSession?.team_id && !loadingAssignees) {
|
||||||
}
|
// dispatch(fetchTaskAssignees(currentSession.team_id));
|
||||||
|
// }
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
[taskGroups, dispatch, currentSession?.team_id, loadingAssignees]
|
[taskGroups, dispatch, currentSession?.team_id, loadingAssignees]
|
||||||
@@ -110,11 +124,31 @@ export const useTaskSocketHandlers = () => {
|
|||||||
async (labels: ILabelsChangeResponse) => {
|
async (labels: ILabelsChangeResponse) => {
|
||||||
if (!labels) return;
|
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([
|
await Promise.all([
|
||||||
dispatch(updateTaskLabel(labels)),
|
dispatch(updateTaskLabel(labels)),
|
||||||
dispatch(setTaskLabels(labels)),
|
dispatch(setTaskLabels(labels)),
|
||||||
dispatch(fetchLabels()),
|
// Remove unnecessary refetches - real-time updates handle this
|
||||||
projectId && dispatch(fetchLabelsByProject(projectId)),
|
// dispatch(fetchLabels()),
|
||||||
|
// projectId && dispatch(fetchLabelsByProject(projectId)),
|
||||||
]);
|
]);
|
||||||
},
|
},
|
||||||
[dispatch, projectId]
|
[dispatch, projectId]
|
||||||
@@ -189,11 +223,12 @@ export const useTaskSocketHandlers = () => {
|
|||||||
}
|
}
|
||||||
}));
|
}));
|
||||||
} else if (!currentGroup || !targetGroup) {
|
} else if (!currentGroup || !targetGroup) {
|
||||||
// Fallback to refetch if groups not found (shouldn't happen normally)
|
// Log the issue but don't refetch - real-time updates should handle this
|
||||||
console.log('🔄 Groups not found, refetching tasks...');
|
console.log('🔄 Groups not found, but avoiding refetch to prevent data thrashing');
|
||||||
if (projectId) {
|
// Remove unnecessary refetch that causes data thrashing
|
||||||
dispatch(fetchTasksV3(projectId));
|
// if (projectId) {
|
||||||
}
|
// dispatch(fetchTasksV3(projectId));
|
||||||
|
// }
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
@@ -611,13 +646,27 @@ export const useTaskSocketHandlers = () => {
|
|||||||
[dispatch, taskGroups]
|
[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
|
// Register socket event listeners
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!socket) return;
|
if (!socket) return;
|
||||||
|
|
||||||
const eventHandlers = [
|
const eventHandlers = [
|
||||||
{ event: SocketEvents.QUICK_ASSIGNEES_UPDATE.toString(), handler: handleAssigneesUpdate },
|
{ 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.TASK_LABELS_CHANGE.toString(), handler: handleLabelsChange },
|
||||||
|
{ event: SocketEvents.CREATE_LABEL.toString(), handler: handleLabelsChange },
|
||||||
{ event: SocketEvents.TASK_STATUS_CHANGE.toString(), handler: handleTaskStatusChange },
|
{ event: SocketEvents.TASK_STATUS_CHANGE.toString(), handler: handleTaskStatusChange },
|
||||||
{ event: SocketEvents.TASK_PROGRESS_UPDATED.toString(), handler: handleTaskProgress },
|
{ event: SocketEvents.TASK_PROGRESS_UPDATED.toString(), handler: handleTaskProgress },
|
||||||
{ event: SocketEvents.TASK_PRIORITY_CHANGE.toString(), handler: handlePriorityChange },
|
{ event: SocketEvents.TASK_PRIORITY_CHANGE.toString(), handler: handlePriorityChange },
|
||||||
@@ -646,6 +695,7 @@ export const useTaskSocketHandlers = () => {
|
|||||||
}, [
|
}, [
|
||||||
socket,
|
socket,
|
||||||
handleAssigneesUpdate,
|
handleAssigneesUpdate,
|
||||||
|
handleTaskAssigneesChange,
|
||||||
handleLabelsChange,
|
handleLabelsChange,
|
||||||
handleTaskStatusChange,
|
handleTaskStatusChange,
|
||||||
handleTaskProgress,
|
handleTaskProgress,
|
||||||
|
|||||||
Reference in New Issue
Block a user