perf(web): isolate sidebar task tree renders (#3435)

Co-authored-by: Kandev Agent <agent@kandev.dev>
This commit is contained in:
João Salavisa
2026-09-06 09:45:29 +01:00
committed by GitHub
parent 7954eba169
commit cafc717dde
8 changed files with 874 additions and 89 deletions
@@ -0,0 +1,56 @@
import { renderHook } from "@testing-library/react";
import { describe, expect, it, vi } from "vitest";
import type { TaskSwitcherItem } from "./task-switcher-types";
const view = {
id: "view-1",
name: "By workflow",
filters: [],
sort: { key: "title" as const, direction: "asc" as const },
group: "workflow" as const,
collapsedGroups: [],
};
const prefs = {
pinnedTaskIds: [],
orderedTaskIds: [],
subtaskOrderByParentId: {},
togglePinnedTask: vi.fn(),
handleReorderGroup: vi.fn(),
handleReorderSubtasks: vi.fn(),
};
vi.mock("react-i18next", () => ({
useTranslation: () => ({ i18n: { language: "en" } }),
}));
vi.mock("@/hooks/domains/sidebar/use-effective-sidebar-view", () => ({
useEffectiveSidebarView: () => view,
}));
vi.mock("@/hooks/domains/sidebar/use-sidebar-task-prefs", () => ({
useSidebarTaskPrefs: () => prefs,
}));
import { useGroupedSidebarView } from "./task-session-sidebar-grouped-view";
function task(id: string, workflowId: string): TaskSwitcherItem {
return { id, title: id, state: "IN_PROGRESS", workflowId, workflowName: workflowId };
}
describe("useGroupedSidebarView", () => {
it("preserves an unaffected group reference when a task in another group updates", () => {
const taskA = task("Task A", "Workflow A");
const taskB = task("Task B", "Workflow B");
const hook = renderHook(({ tasks }) => useGroupedSidebarView(tasks), {
initialProps: { tasks: [taskA, taskB] },
});
const unaffectedGroup = hook.result.current.grouped.groups.find(
(group) => group.key === "Workflow B",
);
hook.rerender({ tasks: [{ ...taskA, title: "Task A updated" }, { ...taskB }] });
expect(hook.result.current.grouped.groups[1]).toBe(unaffectedGroup);
});
});
@@ -1,11 +1,148 @@
"use client";
import { useMemo } from "react";
import { useMemo, useRef } from "react";
import { useTranslation } from "react-i18next";
import { applyView } from "@/lib/sidebar/apply-view";
import { applyView, type GroupedSidebarList, type SidebarGroup } from "@/lib/sidebar/apply-view";
import { useEffectiveSidebarView } from "@/hooks/domains/sidebar/use-effective-sidebar-view";
import { useSidebarTaskPrefs } from "@/hooks/domains/sidebar/use-sidebar-task-prefs";
import type { TaskSwitcherItem } from "./task-switcher";
import type { TaskSwitcherItem } from "./task-switcher-types";
function shallowObjectEqual(previous: object, next: object): boolean {
const previousRecord = previous as Record<string, unknown>;
const nextRecord = next as Record<string, unknown>;
const keys = Object.keys(previousRecord);
return (
keys.length === Object.keys(nextRecord).length &&
keys.every((key) => Object.is(previousRecord[key], nextRecord[key]))
);
}
function taskFieldEqual(previous: unknown, next: unknown): boolean {
if (Object.is(previous, next)) return true;
if (previous === null || next === null) return false;
if (Array.isArray(previous) && Array.isArray(next)) {
return (
previous.length === next.length &&
previous.every((value, index) => {
const nextValue = next[index];
if (Object.is(value, nextValue)) return true;
return (
typeof value === "object" &&
value !== null &&
typeof nextValue === "object" &&
nextValue !== null &&
shallowObjectEqual(value, nextValue)
);
})
);
}
return (
typeof previous === "object" && typeof next === "object" && shallowObjectEqual(previous, next)
);
}
function taskItemEqual(previous: TaskSwitcherItem, next: TaskSwitcherItem): boolean {
const previousRecord = previous as Record<string, unknown>;
const nextRecord = next as Record<string, unknown>;
const keys = Object.keys(previousRecord);
return (
keys.length === Object.keys(nextRecord).length &&
keys.every((key) => taskFieldEqual(previousRecord[key], nextRecord[key]))
);
}
function previousTasksById(grouped: GroupedSidebarList): Map<string, TaskSwitcherItem> {
const result = new Map<string, TaskSwitcherItem>();
for (const group of grouped.groups) {
for (const task of group.tasks) result.set(task.id, task);
}
for (const tasks of grouped.subTasksByParentId.values()) {
for (const task of tasks) result.set(task.id, task);
}
return result;
}
function shareTaskArray(
previous: TaskSwitcherItem[] | undefined,
next: TaskSwitcherItem[],
priorById: Map<string, TaskSwitcherItem>,
): TaskSwitcherItem[] {
const shared = next.map((task) => {
const prior = priorById.get(task.id);
if (!prior || prior === task) return task;
return taskItemEqual(prior, task) ? prior : task;
});
return previous &&
previous.length === shared.length &&
previous.every((task, index) => task === shared[index])
? previous
: shared;
}
function shareGroup(
previous: SidebarGroup | undefined,
next: SidebarGroup,
priorById: Map<string, TaskSwitcherItem>,
): SidebarGroup {
if (!previous || previous.label !== next.label) return next;
const tasks = shareTaskArray(previous.tasks, next.tasks, priorById);
return tasks === previous.tasks ? previous : { ...next, tasks };
}
function shareGroups(
previous: SidebarGroup[],
next: SidebarGroup[],
priorById: Map<string, TaskSwitcherItem>,
): SidebarGroup[] {
const previousByKey = new Map(previous.map((group) => [group.key, group]));
const shared = next.map((group) => shareGroup(previousByKey.get(group.key), group, priorById));
return shared.length === previous.length &&
shared.every((group, index) => group === previous[index])
? previous
: shared;
}
function shareSubtaskMap(
previous: GroupedSidebarList["subTasksByParentId"],
next: GroupedSidebarList["subTasksByParentId"],
priorById: Map<string, TaskSwitcherItem>,
): GroupedSidebarList["subTasksByParentId"] {
let changed = previous.size !== next.size;
const shared = new Map<string, TaskSwitcherItem[]>();
for (const [parentId, tasks] of next) {
const previousTasks = previous.get(parentId);
const value = shareTaskArray(previousTasks, tasks, priorById);
shared.set(parentId, value);
if (value !== previousTasks) changed = true;
}
return changed ? shared : previous;
}
export function shareGroupedSidebarList(
previous: GroupedSidebarList | null,
next: GroupedSidebarList,
): GroupedSidebarList {
// Desktop and mobile build equivalent task models independently. Reusing
// equal models here gives both surfaces the same memoization boundary.
if (!previous || previous.groupKey !== next.groupKey) return next;
const priorById = previousTasksById(previous);
const groups = shareGroups(previous.groups, next.groups, priorById);
const subTasksByParentId = shareSubtaskMap(
previous.subTasksByParentId,
next.subTasksByParentId,
priorById,
);
return groups === previous.groups && subTasksByParentId === previous.subTasksByParentId
? previous
: { ...next, groups, subTasksByParentId };
}
export function useSharedGroupedSidebarList(next: GroupedSidebarList): GroupedSidebarList {
const previousRef = useRef<GroupedSidebarList | null>(null);
const shared = shareGroupedSidebarList(previousRef.current, next);
previousRef.current = shared;
return shared;
}
export function useGroupedSidebarView(displayTasks: TaskSwitcherItem[]) {
const prefs = useSidebarTaskPrefs();
@@ -15,7 +152,7 @@ export function useGroupedSidebarView(displayTasks: TaskSwitcherItem[]) {
// the catalog. Without the language in the deps the group heading keeps the
// previous locale until task data changes.
const { i18n } = useTranslation();
const grouped = useMemo(
const nextGrouped = useMemo(
() =>
applyView(displayTasks, effectiveView, {
pinnedTaskIds,
@@ -31,5 +168,6 @@ export function useGroupedSidebarView(displayTasks: TaskSwitcherItem[]) {
i18n.language,
],
);
const grouped = useSharedGroupedSidebarList(nextGrouped);
return { grouped, effectiveView, prefs };
}
@@ -0,0 +1,257 @@
import type { ReactElement, ReactNode } from "react";
import { cleanup, fireEvent, render, screen } from "@testing-library/react";
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import type { GroupedSidebarList } from "@/lib/sidebar/apply-view";
import type { TaskSwitcherItem } from "./task-switcher-types";
const renderCounts = vi.hoisted(() => ({
groups: new Map<string, number>(),
nodes: new Map<string, number>(),
rows: new Map<string, number>(),
}));
function increment(counts: Map<string, number>, key: string) {
counts.set(key, (counts.get(key) ?? 0) + 1);
}
vi.mock("./task-item", () => ({
TaskItem: ({ title, onClick }: { title: string; onClick?: () => void }) => {
increment(renderCounts.rows, title);
return <button onClick={onClick}>{title}</button>;
},
}));
vi.mock("./task-switcher-context-menu", () => ({
TaskItemWithContextMenu: ({ children }: { children: ReactElement }) => children,
}));
vi.mock("./task-switcher-subtask-dnd", () => ({
SortableTaskLevel: ({
tasks,
renderNode,
}: {
tasks: TaskSwitcherItem[];
renderNode: (task: TaskSwitcherItem, isDraggable: boolean) => ReactNode;
}) => <>{tasks.map((task) => renderNode(task, false))}</>,
SortableTaskNode: ({
taskId,
handle,
nested,
}: {
taskId: string;
handle: ReactNode;
nested?: ReactNode;
}) => {
increment(renderCounts.nodes, taskId);
return (
<>
{handle}
{nested}
</>
);
},
TaskTreeDndGroup: ({
groupTasks,
children,
}: {
groupTasks: TaskSwitcherItem[];
children: (nestTargetIds: Set<string>, activeDragId: string | null) => ReactNode;
}) => {
increment(renderCounts.groups, groupTasks[0]?.workflowId ?? "empty");
return <>{children(new Set(), null)}</>;
},
NestDropZone: () => null,
}));
import { TaskSwitcher } from "./task-switcher";
const onSelectTask = vi.fn();
const onToggleSubtasks = vi.fn();
const TASK_A = "Task A";
const TASK_A_UPDATED = "Task A updated";
const TASK_B = "Task B";
const WORKFLOW_A = "Workflow A";
const WORKFLOW_B = "Workflow B";
function task(id: string, workflowId: string): TaskSwitcherItem {
return { id, title: id, state: "IN_PROGRESS", workflowId, workflowName: workflowId };
}
function groupedByWorkflow(tasks: TaskSwitcherItem[]): GroupedSidebarList {
return {
groups: tasks.map((item) => ({
key: item.workflowId!,
label: item.workflowName!,
tasks: [item],
})),
subTasksByParentId: new Map(),
groupKey: "workflow",
};
}
function groupedTogether(tasks: TaskSwitcherItem[]): GroupedSidebarList {
return {
groups: [{ key: "__all__", label: "All", tasks }],
subTasksByParentId: new Map(),
groupKey: "none",
};
}
function groupedTrees(
entries: Array<{ root: TaskSwitcherItem; child: TaskSwitcherItem }>,
groupKey: "workflow" | "none",
): GroupedSidebarList {
return {
groups:
groupKey === "workflow"
? entries.map(({ root }) => ({
key: root.workflowId!,
label: root.workflowName!,
tasks: [root],
}))
: [{ key: "__all__", label: "All", tasks: entries.map(({ root }) => root) }],
subTasksByParentId: new Map(entries.map(({ root, child }) => [root.id, [child]])),
groupKey,
};
}
function switcher(grouped: GroupedSidebarList, selectTask = onSelectTask) {
return (
<TaskSwitcher
grouped={grouped}
activeTaskId={null}
selectedTaskId={null}
onSelectTask={selectTask}
onToggleSubtasks={onToggleSubtasks}
/>
);
}
beforeEach(() => {
renderCounts.groups.clear();
renderCounts.nodes.clear();
renderCounts.rows.clear();
});
afterEach(() => cleanup());
describe("TaskSwitcher render stability", () => {
it("does not rerender an unaffected expanded group when another group task updates", () => {
const taskA = task(TASK_A, WORKFLOW_A);
const taskB = task(TASK_B, WORKFLOW_B);
const initial = groupedByWorkflow([taskA, taskB]);
const view = render(switcher(initial));
const updated = groupedByWorkflow([{ ...taskA, title: TASK_A_UPDATED }, { ...taskB }]);
view.rerender(switcher(updated));
expect(renderCounts.groups.get(WORKFLOW_B)).toBe(1);
});
it("does not rerender an unaffected row beside an updated task", () => {
const taskA = task(TASK_A, WORKFLOW_A);
const taskB = task(TASK_B, WORKFLOW_A);
const initial = groupedTogether([taskA, taskB]);
const view = render(switcher(initial));
const updated = groupedTogether([{ ...taskA, title: TASK_A_UPDATED }, { ...taskB }]);
view.rerender(switcher(updated));
expect({
updated: renderCounts.rows.get(TASK_A_UPDATED),
unaffected: renderCounts.rows.get(TASK_B),
}).toEqual({ updated: 1, unaffected: 1 });
});
it("does not rerender an unaffected row when task-derived handlers are recreated", () => {
const taskA = task(TASK_A, WORKFLOW_A);
const taskB = task(TASK_B, WORKFLOW_A);
const initial = groupedTogether([taskA, taskB]);
const view = render(switcher(initial, vi.fn()));
const updated = groupedTogether([{ ...taskA, title: TASK_A_UPDATED }, { ...taskB }]);
view.rerender(switcher(updated, vi.fn()));
expect(renderCounts.rows.get(TASK_B)).toBe(1);
});
it("routes a skipped row action to the latest committed handler", () => {
const taskA = task(TASK_A, WORKFLOW_A);
const taskB = task(TASK_B, WORKFLOW_A);
const initial = groupedTogether([taskA, taskB]);
const view = render(switcher(initial, vi.fn()));
const latestHandler = vi.fn();
view.rerender(
switcher(groupedTogether([{ ...taskA, title: TASK_A_UPDATED }, { ...taskB }]), latestHandler),
);
fireEvent.click(screen.getByRole("button", { name: TASK_B }));
expect(latestHandler).toHaveBeenCalledWith(TASK_B);
});
});
describe("TaskSwitcher nested render stability", () => {
it("does not rerender an unaffected expanded group when another group subtask updates", () => {
const parentA = task("Parent A", WORKFLOW_A);
const childA = { ...task("Child A", WORKFLOW_A), parentTaskId: parentA.id };
const parentB = task("Parent B", WORKFLOW_B);
const childB = { ...task("Child B", WORKFLOW_B), parentTaskId: parentB.id };
const initial = groupedTrees(
[
{ root: parentA, child: childA },
{ root: parentB, child: childB },
],
"workflow",
);
const view = render(switcher(initial));
const updated = groupedTrees(
[
{ root: { ...parentA }, child: { ...childA, title: "Child A updated" } },
{ root: { ...parentB }, child: { ...childB } },
],
"workflow",
);
view.rerender(switcher(updated));
expect(renderCounts.groups.get(WORKFLOW_B)).toBe(1);
});
it("does not rerender an unaffected expandable row beside an updated subtask", () => {
const parentA = task("Parent A", WORKFLOW_A);
const childA = { ...task("Child A", WORKFLOW_A), parentTaskId: parentA.id };
const parentB = task("Parent B", WORKFLOW_A);
const childB = { ...task("Child B", WORKFLOW_A), parentTaskId: parentB.id };
const initial = groupedTrees(
[
{ root: parentA, child: childA },
{ root: parentB, child: childB },
],
"none",
);
const view = render(switcher(initial));
const updated = groupedTrees(
[
{ root: { ...parentA }, child: { ...childA, title: "Child A updated" } },
{ root: { ...parentB }, child: { ...childB } },
],
"none",
);
view.rerender(switcher(updated));
expect(renderCounts.rows.get("Parent B")).toBe(1);
});
it("does not rebuild an unaffected sibling tree node when a task updates", () => {
const taskA = task(TASK_A, WORKFLOW_A);
const taskB = task(TASK_B, WORKFLOW_A);
const initial = groupedTogether([taskA, taskB]);
const view = render(switcher(initial));
view.rerender(switcher(groupedTogether([{ ...taskA, title: TASK_A_UPDATED }, { ...taskB }])));
expect(renderCounts.nodes.get(TASK_B)).toBe(1);
});
});
@@ -1,6 +1,6 @@
"use client";
import type { ReactNode } from "react";
import { memo, type ReactNode } from "react";
import type { StepDef, TaskLinkHandler, TaskSwitcherItem } from "./task-switcher-types";
import { TaskItem } from "./task-item";
import { TaskItemWithContextMenu } from "./task-switcher-context-menu";
@@ -198,7 +198,7 @@ function TaskRowItem({
);
}
export function TaskRow(props: TaskRowProps) {
export const TaskRow = memo(function TaskRow(props: TaskRowProps) {
const { task, isSubTask, depth, subtaskToggle, workflows, stepsByWorkflowId } = props;
const isArchived = task.isArchived === true;
return (
@@ -218,4 +218,4 @@ export function TaskRow(props: TaskRowProps) {
/>
</TaskItemWithContextMenu>
);
}
});
+137 -25
View File
@@ -1,6 +1,6 @@
"use client";
import { useMemo } from "react";
import { memo, useCallback, useMemo } from "react";
import { countGroupTasks, type SidebarGroup } from "@/lib/sidebar/apply-view";
import {
SortableTaskLevel,
@@ -28,34 +28,90 @@ type TaskTreeContext = {
onNestTask?: (taskId: string, parentTaskId: string) => void;
// Rows in nestTargetIds render a nest drop zone while a drag is active.
nestTargetIds: Set<string>;
activeDragId: string | null;
// The group renders one DndContext spanning every level (TaskTreeDndGroup),
// so levels must not create their own.
externalDragContext: boolean;
};
function TaskTreeNode({
task,
depth,
ctx,
isDraggable,
}: {
type TaskTreeNodeProps = {
task: TaskSwitcherItem;
depth: number;
ctx: TaskTreeContext;
isDraggable: boolean;
}) {
};
function nodeSubtreeEqual(
taskId: string,
previous: TaskTreeContext,
next: TaskTreeContext,
visited: Set<string>,
): boolean {
if (visited.has(taskId)) return true;
visited.add(taskId);
if (
previous.collapsedSubs.has(taskId) !== next.collapsedSubs.has(taskId) ||
previous.nestTargetIds.has(taskId) !== next.nestTargetIds.has(taskId)
) {
return false;
}
const previousChildren = previous.subTasksByParentId.get(taskId);
const nextChildren = next.subTasksByParentId.get(taskId);
if (!previousChildren || !nextChildren) return previousChildren === nextChildren;
if (
previousChildren.length !== nextChildren.length ||
previousChildren.some((task, index) => task !== nextChildren[index])
) {
return false;
}
return previousChildren.every((task) => nodeSubtreeEqual(task.id, previous, next, visited));
}
function taskTreeNodeEqual(previous: TaskTreeNodeProps, next: TaskTreeNodeProps): boolean {
// A node owns its rendered descendants, so its equality includes only that
// branch's collapse, nest-target, and task references from the shared maps.
if (
previous.task !== next.task ||
previous.depth !== next.depth ||
previous.isDraggable !== next.isDraggable ||
previous.ctx.rowProps !== next.ctx.rowProps ||
previous.ctx.onToggleSubtasks !== next.ctx.onToggleSubtasks ||
previous.ctx.onReorderGroup !== next.ctx.onReorderGroup ||
previous.ctx.onReorderSubtasks !== next.ctx.onReorderSubtasks ||
previous.ctx.onNestTask !== next.ctx.onNestTask ||
previous.ctx.externalDragContext !== next.ctx.externalDragContext ||
(previous.depth === 0 &&
previous.ctx.pinnedSet.has(previous.task.id) !== next.ctx.pinnedSet.has(next.task.id))
) {
return false;
}
return nodeSubtreeEqual(previous.task.id, previous.ctx, next.ctx, new Set());
}
const TaskTreeNode = memo(function TaskTreeNode({
task,
depth,
ctx,
isDraggable,
}: TaskTreeNodeProps) {
const subs = ctx.subTasksByParentId.get(task.id);
const hasSubs = !!subs?.length;
const subsHidden = hasSubs && !!ctx.onToggleSubtasks && ctx.collapsedSubs.has(task.id);
const toggleInfo: SubtaskToggleInfo | undefined =
hasSubs && ctx.onToggleSubtasks
? {
subtaskCount: countGroupTasks(subs, ctx.subTasksByParentId),
subtasksCollapsed: subsHidden,
onToggleSubtasks: () => ctx.onToggleSubtasks!(task.id),
}
: undefined;
const subtaskCount = hasSubs ? countGroupTasks(subs, ctx.subTasksByParentId) : 0;
const handleToggleSubtasks = useCallback(
() => ctx.onToggleSubtasks?.(task.id),
[ctx.onToggleSubtasks, task.id],
);
const toggleInfo: SubtaskToggleInfo | undefined = useMemo(
() =>
hasSubs && ctx.onToggleSubtasks
? {
subtaskCount,
subtasksCollapsed: subsHidden,
onToggleSubtasks: handleToggleSubtasks,
}
: undefined,
[handleToggleSubtasks, hasSubs, ctx.onToggleSubtasks, subsHidden, subtaskCount],
);
const isRoot = depth === 0;
const isNestTarget = ctx.nestTargetIds.has(task.id);
const handle = (
@@ -87,7 +143,7 @@ function TaskTreeNode({
isDraggable={isDraggable}
/>
);
}
}, taskTreeNodeEqual);
function TaskTreeLevel({
parentTaskId,
@@ -131,7 +187,7 @@ export type GroupSectionProps = {
rowProps: TaskRowBaseProps;
pinnedSet: Set<string>;
isCollapsed: boolean;
onToggleCollapsed: () => void;
onToggleGroup?: (groupKey: string) => void;
collapsedSubtaskParentIds?: string[];
onToggleSubtasks?: (parentTaskId: string) => void;
showHeader: boolean;
@@ -140,6 +196,63 @@ export type GroupSectionProps = {
onNestTask?: (taskId: string, parentTaskId: string) => void;
};
function sameTaskSubtree(
previousTasks: TaskSwitcherItem[],
nextTasks: TaskSwitcherItem[],
previousMap: Map<string, TaskSwitcherItem[]>,
nextMap: Map<string, TaskSwitcherItem[]>,
visited = new Set<string>(),
): boolean {
if (
previousTasks.length !== nextTasks.length ||
previousTasks.some((task, index) => task !== nextTasks[index])
) {
return false;
}
for (const task of previousTasks) {
if (visited.has(task.id)) continue;
visited.add(task.id);
const previousChildren = previousMap.get(task.id);
const nextChildren = nextMap.get(task.id);
if (!previousChildren && !nextChildren) continue;
if (
!previousChildren ||
!nextChildren ||
!sameTaskSubtree(previousChildren, nextChildren, previousMap, nextMap, visited)
) {
return false;
}
}
return true;
}
function groupSectionEqual(previous: GroupSectionProps, next: GroupSectionProps): boolean {
if (
previous.group !== next.group ||
previous.rowProps !== next.rowProps ||
previous.pinnedSet !== next.pinnedSet ||
previous.isCollapsed !== next.isCollapsed ||
previous.onToggleGroup !== next.onToggleGroup ||
previous.collapsedSubtaskParentIds !== next.collapsedSubtaskParentIds ||
previous.onToggleSubtasks !== next.onToggleSubtasks ||
previous.showHeader !== next.showHeader ||
previous.onReorderGroup !== next.onReorderGroup ||
previous.onReorderSubtasks !== next.onReorderSubtasks ||
previous.onNestTask !== next.onNestTask
) {
return false;
}
return (
previous.subTasksByParentId === next.subTasksByParentId ||
sameTaskSubtree(
previous.group.tasks,
next.group.tasks,
previous.subTasksByParentId,
next.subTasksByParentId,
)
);
}
/**
* Flattens one group's subtree (roots + every descendant, in rendered order)
* so the group-spanning DndContext can resolve reorder levels and nest
@@ -170,13 +283,13 @@ function flattenGroupTasks(
* Renders one sidebar group's task tree (header + the recursive tree wrapped
* in the group-spanning DndContext that enables nest-by-drag).
*/
export function GroupSection({
export const GroupSection = memo(function GroupSection({
group,
subTasksByParentId,
rowProps,
pinnedSet,
isCollapsed,
onToggleCollapsed,
onToggleGroup,
collapsedSubtaskParentIds,
onToggleSubtasks,
showHeader,
@@ -190,7 +303,7 @@ export function GroupSection({
[group.tasks, subTasksByParentId],
);
const renderTree = (nestTargetIds: Set<string>, activeDragId: string | null) => {
const renderTree = (nestTargetIds: Set<string>) => {
if (isCollapsed) return null;
const ctx: TaskTreeContext = {
subTasksByParentId,
@@ -202,7 +315,6 @@ export function GroupSection({
onReorderSubtasks,
onNestTask,
nestTargetIds,
activeDragId,
externalDragContext: true,
};
return <TaskTreeLevel parentTaskId={null} tasks={group.tasks} depth={0} ctx={ctx} />;
@@ -216,7 +328,7 @@ export function GroupSection({
groupKey={group.key}
count={totalCount}
isCollapsed={isCollapsed}
onToggle={onToggleCollapsed}
onToggle={() => onToggleGroup?.(group.key)}
/>
)}
<TaskTreeDndGroup
@@ -229,4 +341,4 @@ export function GroupSection({
</TaskTreeDndGroup>
</div>
);
}
}, groupSectionEqual);
+117 -30
View File
@@ -1,9 +1,10 @@
"use client";
import { memo, useMemo } from "react";
import { memo, useLayoutEffect, useMemo, useRef } from "react";
import { useTranslation } from "react-i18next";
import { TaskSwitcherSkeleton } from "./task-switcher-group";
import { GroupSection, type GroupSectionProps, type TaskRowBaseProps } from "./task-switcher-tree";
import { useSharedGroupedSidebarList } from "./task-session-sidebar-grouped-view";
import type { TaskSwitcherProps } from "./task-switcher-types";
export type {
@@ -23,7 +24,69 @@ function shouldShowRowRepository(grouped: TaskSwitcherProps["grouped"]): boolean
return grouped.groupKey !== "repository";
}
function buildTaskRowProps(props: TaskSwitcherProps): TaskRowBaseProps {
const TASK_ROW_HANDLER_KEYS = [
"onSelectTask",
"onEditTask",
"onRenameTask",
"onArchiveTask",
"onCreateSubtask",
"onDeleteTask",
"onDetachTask",
"onLinkPullRequest",
"onLinkIssue",
"onLinkMergeRequest",
"onLinkJiraTicket",
"onLinkLinearIssue",
"onLinkSentryIssue",
"onMoveToStep",
"onTogglePin",
"onToggleSelectTask",
"onSelectTaskRange",
"onBulkArchive",
"onBulkDelete",
"onBulkPin",
"onBulkMove",
"onClearSelection",
] as const;
type TaskRowHandlerKey = (typeof TASK_ROW_HANDLER_KEYS)[number];
type StableTaskRowHandlers = {
[Key in TaskRowHandlerKey]-?: NonNullable<TaskSwitcherProps[Key]>;
};
function useStableTaskRowHandlers(props: TaskSwitcherProps): StableTaskRowHandlers {
// A memoized row must keep its function identities without retaining a
// range-selection or dialog handler from an older committed task tree.
const committedPropsRef = useRef(props);
useLayoutEffect(() => {
committedPropsRef.current = props;
}, [props]);
return useMemo(() => {
const handlers: Partial<Record<TaskRowHandlerKey, (...args: unknown[]) => unknown>> = {};
for (const key of TASK_ROW_HANDLER_KEYS) {
handlers[key] = (...args: unknown[]) => {
const handler = committedPropsRef.current[key];
if (typeof handler === "function") {
return (handler as (...handlerArgs: unknown[]) => unknown)(...args);
}
};
}
return handlers as StableTaskRowHandlers;
}, []);
}
function optionalHandler<Key extends TaskRowHandlerKey>(
props: TaskSwitcherProps,
handlers: StableTaskRowHandlers,
key: Key,
): StableTaskRowHandlers[Key] | undefined {
return props[key] ? handlers[key] : undefined;
}
function buildTaskRowProps(
props: TaskSwitcherProps,
handlers: StableTaskRowHandlers,
): TaskRowBaseProps {
return {
workflows: props.workflows,
stepsByWorkflowId: props.stepsByWorkflowId,
@@ -32,39 +95,59 @@ function buildTaskRowProps(props: TaskSwitcherProps): TaskRowBaseProps {
showActivityTime: props.showActivityTime,
taskRowPresentation: props.taskRowPresentation,
showRepository: shouldShowRowRepository(props.grouped),
onSelectTask: props.onSelectTask,
onEditTask: props.onEditTask,
onRenameTask: props.onRenameTask,
onArchiveTask: props.onArchiveTask,
onCreateSubtask: props.onCreateSubtask,
onDeleteTask: props.onDeleteTask,
onDetachTask: props.onDetachTask,
onLinkPullRequest: props.onLinkPullRequest,
onLinkIssue: props.onLinkIssue,
onLinkMergeRequest: props.onLinkMergeRequest,
onLinkJiraTicket: props.onLinkJiraTicket,
onLinkLinearIssue: props.onLinkLinearIssue,
onLinkSentryIssue: props.onLinkSentryIssue,
onMoveToStep: props.onMoveToStep,
onTogglePin: props.onTogglePin,
onSelectTask: handlers.onSelectTask,
onEditTask: optionalHandler(props, handlers, "onEditTask"),
onRenameTask: optionalHandler(props, handlers, "onRenameTask"),
onArchiveTask: optionalHandler(props, handlers, "onArchiveTask"),
onCreateSubtask: optionalHandler(props, handlers, "onCreateSubtask"),
onDeleteTask: optionalHandler(props, handlers, "onDeleteTask"),
onDetachTask: optionalHandler(props, handlers, "onDetachTask"),
onLinkPullRequest: optionalHandler(props, handlers, "onLinkPullRequest"),
onLinkIssue: optionalHandler(props, handlers, "onLinkIssue"),
onLinkMergeRequest: optionalHandler(props, handlers, "onLinkMergeRequest"),
onLinkJiraTicket: optionalHandler(props, handlers, "onLinkJiraTicket"),
onLinkLinearIssue: optionalHandler(props, handlers, "onLinkLinearIssue"),
onLinkSentryIssue: optionalHandler(props, handlers, "onLinkSentryIssue"),
onMoveToStep: optionalHandler(props, handlers, "onMoveToStep"),
onTogglePin: optionalHandler(props, handlers, "onTogglePin"),
pinnedTaskIds: props.pinnedTaskIds,
deletingTaskId: props.deletingTaskId,
archivingTaskId: props.archivingTaskId,
isArchiving: props.isArchiving,
selectedTaskIds: props.selectedTaskIds,
onToggleSelectTask: props.onToggleSelectTask,
onSelectTaskRange: props.onSelectTaskRange,
onBulkArchive: props.onBulkArchive,
onBulkDelete: props.onBulkDelete,
onBulkPin: props.onBulkPin,
onBulkMove: props.onBulkMove,
onClearSelection: props.onClearSelection,
onToggleSelectTask: optionalHandler(props, handlers, "onToggleSelectTask"),
onSelectTaskRange: optionalHandler(props, handlers, "onSelectTaskRange"),
onBulkArchive: optionalHandler(props, handlers, "onBulkArchive"),
onBulkDelete: optionalHandler(props, handlers, "onBulkDelete"),
onBulkPin: optionalHandler(props, handlers, "onBulkPin"),
onBulkMove: optionalHandler(props, handlers, "onBulkMove"),
onClearSelection: optionalHandler(props, handlers, "onClearSelection"),
isMixedWorkflowSelection: props.isMixedWorkflowSelection,
};
}
function shallowRowPropsEqual(previous: TaskRowBaseProps, next: TaskRowBaseProps): boolean {
const keys = Object.keys(previous) as Array<keyof TaskRowBaseProps>;
return (
keys.length === Object.keys(next).length &&
keys.every((key) => Object.is(previous[key], next[key]))
);
}
function useTaskRowProps(props: TaskSwitcherProps): TaskRowBaseProps {
const handlers = useStableTaskRowHandlers(props);
const previousRef = useRef<TaskRowBaseProps | null>(null);
const next = buildTaskRowProps(props, handlers);
if (previousRef.current && shallowRowPropsEqual(previousRef.current, next)) {
return previousRef.current;
}
previousRef.current = next;
return next;
}
function buildGroupSectionProps(
props: TaskSwitcherProps,
grouped: TaskSwitcherProps["grouped"],
options: {
group: GroupSectionProps["group"];
rowProps: TaskRowBaseProps;
@@ -76,11 +159,11 @@ function buildGroupSectionProps(
const { group, rowProps, pinnedSet, collapsedSet, showHeader } = options;
return {
group,
subTasksByParentId: props.grouped.subTasksByParentId,
subTasksByParentId: grouped.subTasksByParentId,
rowProps,
pinnedSet,
isCollapsed: collapsedSet.has(group.key),
onToggleCollapsed: () => props.onToggleGroup?.(group.key),
onToggleGroup: props.onToggleGroup,
collapsedSubtaskParentIds: props.collapsedSubtaskParentIds,
onToggleSubtasks: props.onToggleSubtasks,
showHeader,
@@ -117,8 +200,14 @@ function LoadErrorNotice({
export const TaskSwitcher = memo(function TaskSwitcher(props: TaskSwitcherProps) {
const { t } = useTranslation("sidebar");
const { grouped, isLoading = false, loadError, onRetryLoad, retryLabel, totalTaskCount } = props;
const { isLoading = false, loadError, onRetryLoad, retryLabel, totalTaskCount } = props;
const grouped = useSharedGroupedSidebarList(props.grouped);
const pinnedSet = useMemo(() => new Set(props.pinnedTaskIds ?? []), [props.pinnedTaskIds]);
const rowProps = useTaskRowProps(props);
const collapsedSet = useMemo(
() => new Set(props.collapsedGroupKeys ?? []),
[props.collapsedGroupKeys],
);
if (isLoading) return <TaskSwitcherSkeleton />;
@@ -141,11 +230,9 @@ export const TaskSwitcher = memo(function TaskSwitcher(props: TaskSwitcherProps)
);
}
const collapsedSet = new Set(props.collapsedGroupKeys ?? []);
const showHeaders =
grouped.groups.length > 1 ||
(grouped.groups.length === 1 && grouped.groups[0].key !== "__all__");
const rowProps = buildTaskRowProps(props);
return (
<div>
@@ -153,7 +240,7 @@ export const TaskSwitcher = memo(function TaskSwitcher(props: TaskSwitcherProps)
{grouped.groups.map((group) => (
<GroupSection
key={group.key}
{...buildGroupSectionProps(props, {
{...buildGroupSectionProps(props, grouped, {
group,
rowProps,
pinnedSet,
@@ -181,6 +181,67 @@ describe("useWorkspaceSidebarTasks", () => {
});
});
describe("useWorkspaceSidebarTasks reference stability", () => {
beforeEach(() => {
vi.clearAllMocks();
mockState = {
kanbanMulti: { snapshots: {}, isLoading: false },
workflows: { items: [] },
kanban: { workflowId: null, tasks: [], steps: [] },
};
});
it("preserves an unaffected task reference when a sibling task updates", () => {
const snapshot = makeSnapshot("wf-A", "Alpha", ["t-a1", "t-a2"]);
setMockState({
workflows: { items: [{ id: "wf-A", workspaceId: "ws-1", name: "Alpha" }] },
kanbanMulti: { snapshots: { "wf-A": snapshot }, isLoading: false },
});
const view = renderHook(() => useWorkspaceSidebarTasks("ws-1"));
const unaffectedTask = view.result.current.allTasks[1];
setMockState({
kanbanMulti: {
snapshots: {
"wf-A": {
...snapshot,
tasks: [{ ...snapshot.tasks[0], title: "Updated" }, snapshot.tasks[1]],
},
},
isLoading: false,
},
});
view.rerender();
expect(view.result.current.allTasks[1]).toBe(unaffectedTask);
});
it("preserves workflow step references when only a task updates", () => {
const snapshot = makeSnapshot("wf-A", "Alpha", ["t-a1", "t-a2"]);
setMockState({
workflows: { items: [{ id: "wf-A", workspaceId: "ws-1", name: "Alpha" }] },
kanbanMulti: { snapshots: { "wf-A": snapshot }, isLoading: false },
});
const view = renderHook(() => useWorkspaceSidebarTasks("ws-1"));
const previousSteps = view.result.current.stepsByWorkflowId;
setMockState({
kanbanMulti: {
snapshots: {
"wf-A": {
...snapshot,
tasks: [{ ...snapshot.tasks[0], title: "Updated" }, snapshot.tasks[1]],
},
},
isLoading: false,
},
});
view.rerender();
expect(view.result.current.stepsByWorkflowId).toBe(previousSteps);
});
});
describe("useWorkspaceSidebarTasks WIP queue", () => {
beforeEach(() => {
mockState = {
@@ -1,4 +1,4 @@
import { useMemo } from "react";
import { useMemo, useRef } from "react";
import { useAppStore } from "@/components/state-provider";
import { useAllWorkflowSnapshots } from "@/hooks/domains/kanban/use-all-workflow-snapshots";
import { useSidebarArchivedTasks } from "@/hooks/domains/kanban/use-sidebar-archived-tasks";
@@ -19,6 +19,93 @@ export type WorkspaceSidebarTasksResult = AggregatedSidebarTasks & {
retryArchivedTasks: () => void;
};
type SidebarTask = AggregatedSidebarTasks["allTasks"][number];
function shallowTaskEqual(previous: SidebarTask, next: SidebarTask): boolean {
const previousKeys = Object.keys(previous) as Array<keyof SidebarTask>;
const nextKeys = Object.keys(next) as Array<keyof SidebarTask>;
return (
previousKeys.length === nextKeys.length &&
nextKeys.every((key) => Object.is(previous[key], next[key]))
);
}
function reuseUnchangedTasks(previous: SidebarTask[], next: SidebarTask[]): SidebarTask[] {
// Aggregation stamps `_workflowId` onto fresh objects. Restore the store's
// per-task structural sharing so downstream view models can stay granular.
const previousById = new Map(previous.map((task) => [task.id, task]));
let changed = previous.length !== next.length;
const shared = next.map((task, index) => {
const prior = previousById.get(task.id);
const value = prior && shallowTaskEqual(prior, task) ? prior : task;
if (value !== previous[index]) changed = true;
return value;
});
return changed ? shared : previous;
}
function reuseReferenceArray<T>(previous: T[], next: T[]): T[] {
return previous.length === next.length && previous.every((value, index) => value === next[index])
? previous
: next;
}
function reuseStepRecord(
previous: AggregatedSidebarTasks["stepsByWorkflowId"],
next: AggregatedSidebarTasks["stepsByWorkflowId"],
): AggregatedSidebarTasks["stepsByWorkflowId"] {
const previousKeys = Object.keys(previous);
const nextKeys = Object.keys(next);
let changed = previousKeys.length !== nextKeys.length;
const shared: AggregatedSidebarTasks["stepsByWorkflowId"] = {};
for (const workflowId of nextKeys) {
const steps = reuseReferenceArray(previous[workflowId] ?? [], next[workflowId]);
shared[workflowId] = steps;
if (steps !== previous[workflowId]) changed = true;
}
return changed ? shared : previous;
}
function useSharedStepMetadata(aggregated: AggregatedSidebarTasks) {
const previousAllStepsRef = useRef<AggregatedSidebarTasks["allSteps"]>([]);
const allSteps = reuseReferenceArray(previousAllStepsRef.current, aggregated.allSteps);
previousAllStepsRef.current = allSteps;
const previousStepsByWorkflowRef = useRef<AggregatedSidebarTasks["stepsByWorkflowId"]>({});
const stepsByWorkflowId = reuseStepRecord(
previousStepsByWorkflowRef.current,
aggregated.stepsByWorkflowId,
);
previousStepsByWorkflowRef.current = stepsByWorkflowId;
return { allSteps, stepsByWorkflowId };
}
function buildWipQueueByTaskId(
allTasks: SidebarTask[],
allSteps: AggregatedSidebarTasks["allSteps"],
stepsByWorkflowId: AggregatedSidebarTasks["stepsByWorkflowId"],
): Map<string, WipQueueStatus> {
const result = new Map<string, WipQueueStatus>();
const activeTasks = allTasks.filter((task) => task.isArchived !== true);
const destinationStepIds = new Set(
activeTasks.map((task) => task.queuedForStepId).filter((stepId): stepId is string => !!stepId),
);
for (const stepId of destinationStepIds) {
for (const entry of getDestinationQueue(activeTasks, stepId)) {
const task = entry.task;
const stepTitle =
stepsByWorkflowId[task._workflowId]?.find((step) => step.id === stepId)?.title ??
allSteps.find((step) => step.id === stepId)?.title ??
stepId;
result.set(task.id, {
position: entry.position,
total: entry.total,
destinationTitle: stepTitle,
});
}
}
return result;
}
export function mergeSidebarArchivedTasks(
activeTasks: AggregatedSidebarTasks["allTasks"],
archivedTasks: KanbanState["tasks"],
@@ -112,40 +199,25 @@ export function useWorkspaceSidebarTasks(workspaceId: string | null): WorkspaceS
[scopedSnapshots, fallbackWorkflowId, activeKanbanTasks, activeKanbanSteps],
);
const { allSteps, stepsByWorkflowId } = useSharedStepMetadata(aggregated);
const previousTasksRef = useRef<SidebarTask[]>([]);
const allTasks = useMemo(() => {
return mergeSidebarArchivedTasks(
const merged = mergeSidebarArchivedTasks(
aggregated.allTasks,
archived.tasks,
workspaceId,
needsArchivedTasks,
);
const shared = reuseUnchangedTasks(previousTasksRef.current, merged);
previousTasksRef.current = shared;
return shared;
}, [aggregated.allTasks, archived.tasks, needsArchivedTasks, workspaceId]);
const wipQueueByTaskId = useMemo(() => {
const result = new Map<string, WipQueueStatus>();
const activeTasks = allTasks.filter((task) => task.isArchived !== true);
const destinationStepIds = new Set(
activeTasks
.map((task) => task.queuedForStepId)
.filter((stepId): stepId is string => !!stepId),
);
for (const stepId of destinationStepIds) {
for (const entry of getDestinationQueue(activeTasks, stepId)) {
const task = entry.task;
const stepTitle =
aggregated.stepsByWorkflowId[task._workflowId]?.find((step) => step.id === stepId)
?.title ??
aggregated.allSteps.find((step) => step.id === stepId)?.title ??
stepId;
result.set(task.id, {
position: entry.position,
total: entry.total,
destinationTitle: stepTitle,
});
}
}
return result;
}, [allTasks, aggregated.allSteps, aggregated.stepsByWorkflowId]);
const wipQueueByTaskId = useMemo(
() => buildWipQueueByTaskId(allTasks, allSteps, stepsByWorkflowId),
[allTasks, allSteps, stepsByWorkflowId],
);
const workspaceWorkflows = useMemo<TaskMoveWorkflow[]>(
() => filteredWorkflows.map((w) => ({ id: w.id, name: w.name, hidden: w.hidden })),
@@ -160,6 +232,8 @@ export function useWorkspaceSidebarTasks(workspaceId: string | null): WorkspaceS
return {
...aggregated,
allTasks,
allSteps,
stepsByWorkflowId,
wipQueueByTaskId,
workflows: workspaceWorkflows,
isLoading,