Files
kandev/apps/web/components/task/task-switcher-subtask-dnd.tsx
T
José Almeida d145874e3b perf(web): eliminate sidebar close lag with many tasks (#1355)
* perf(web): eliminate sidebar close lag with many tasks

Closing the app sidebar unmounted the entire task list in the same frame
the width animation started, so close lag scaled with task count.

- Keep the Tasks grow-section mounted but CSS-hidden in rail mode:
  collapse is now O(1) and reopening is instant (no remount).
- Memoize onToggleGroup so TaskSwitcher's memo() holds; an inline arrow
  re-rendered every task row on each sidebar render.
- Animate only width (transition-[width]) instead of transition-all.
- content-visibility:auto on task blocks skips layout/paint for rows
  outside the sidebar scrollport in long lists.

* test(web): add round-trip collapse/expand assertion to sidebar section test
2026-06-12 14:49:23 +01:00

177 lines
5.3 KiB
TypeScript

"use client";
import { useCallback, useMemo } from "react";
import {
DndContext,
PointerSensor,
closestCenter,
type DragEndEvent,
useSensor,
useSensors,
} from "@dnd-kit/core";
import {
SortableContext,
arrayMove,
useSortable,
verticalListSortingStrategy,
} from "@dnd-kit/sortable";
import { CSS } from "@dnd-kit/utilities";
import { cn } from "@/lib/utils";
import type { TaskSwitcherItem } from "./task-switcher";
export const DRAG_ACTIVATION_DISTANCE = 8;
// Skip layout/paint for rows outside the sidebar scrollport so long task lists
// stay cheap to render and scroll. `auto 52px` seeds the height estimate before
// first paint; afterwards the browser remembers each row's real size, so
// scrollbar/anchor behavior stays stable and dnd-kit's drag-start rect
// measurements remain accurate for previously rendered rows.
const OFFSCREEN_SKIP = "[content-visibility:auto] [contain-intrinsic-block-size:auto_52px]";
/** Sortable implementation — isolated so `useSortable` is never called conditionally. */
function DraggableSortableTaskNode({
taskId,
depth,
handle,
nested,
}: {
taskId: string;
depth: number;
handle: React.ReactNode;
nested?: React.ReactNode;
}) {
const { attributes, listeners, setNodeRef, transform, transition, isDragging } = useSortable({
id: taskId,
});
const sortableAttributes = {
...attributes,
role: undefined,
"aria-roledescription": undefined,
};
const style = {
transform: CSS.Transform.toString(transform),
transition,
opacity: isDragging ? 0.5 : undefined,
};
return (
<div
ref={setNodeRef}
style={style}
data-testid="sortable-task-block"
data-task-id={taskId}
data-depth={depth}
className={cn(OFFSCREEN_SKIP, isDragging && "z-50")}
>
<div
{...sortableAttributes}
{...listeners}
// Strip dnd-kit's default tabIndex={0}: only PointerSensor is wired,
// so keyboard tab stops here lead nowhere. If KeyboardSensor is
// added later, drop this override.
tabIndex={undefined}
data-testid="sortable-task-handle"
className="cursor-grab active:cursor-grabbing"
>
{handle}
</div>
{nested}
</div>
);
}
/**
* A single sortable node in the (arbitrarily deep) task tree.
*
* `setNodeRef` and the CSS transform live on the outer wrapper so the row and
* its nested subtree move together while dragging. Drag listeners attach ONLY
* to the `handle` (the task row) — `nested` (the child subtree) renders as a
* sibling outside the handle so a pointer-down on a descendant row drags that
* descendant, not this node. This is the same handle/children split the root
* level has always used; generalizing it is what makes nesting safe at depth.
*
* When `isDraggable` is false, renders a plain wrapper with no DnD wiring.
*/
export function SortableTaskNode({
taskId,
depth,
handle,
nested,
isDraggable = true,
}: {
taskId: string;
depth: number;
handle: React.ReactNode;
nested?: React.ReactNode;
isDraggable?: boolean;
}) {
if (!isDraggable) {
return (
<div
data-testid="sortable-task-block"
data-task-id={taskId}
data-depth={depth}
className={OFFSCREEN_SKIP}
>
<div data-testid="sortable-task-handle">{handle}</div>
{nested}
</div>
);
}
return (
<DraggableSortableTaskNode taskId={taskId} depth={depth} handle={handle} nested={nested} />
);
}
/**
* DnD wiring for one level of sibling tasks. Reordering is scoped to the
* siblings sharing a parent (cross-parent drag is intentionally unsupported).
*/
function useLevelDnd(tasks: TaskSwitcherItem[], onReorder?: (orderedTaskIds: string[]) => void) {
const sensors = useSensors(
useSensor(PointerSensor, { activationConstraint: { distance: DRAG_ACTIVATION_DISTANCE } }),
);
const handleDragEnd = useCallback(
(event: DragEndEvent) => {
if (!onReorder) return;
const { active, over } = event;
if (!over || active.id === over.id) return;
const ids = tasks.map((t) => t.id);
const oldIndex = ids.indexOf(String(active.id));
const newIndex = ids.indexOf(String(over.id));
if (oldIndex < 0 || newIndex < 0) return;
onReorder(arrayMove(ids, oldIndex, newIndex));
},
[tasks, onReorder],
);
const sortableIds = useMemo(() => tasks.map((t) => t.id), [tasks]);
return { sensors, handleDragEnd, sortableIds };
}
/**
* Renders one level of sibling task nodes. When `onReorder` is omitted the DnD
* scaffolding (and the misleading grab cursor) is skipped, so non-reorderable
* callers get a plain list with no wasted setup.
*/
export function SortableTaskLevel({
tasks,
onReorder,
renderNode,
}: {
tasks: TaskSwitcherItem[];
onReorder?: (orderedTaskIds: string[]) => void;
renderNode: (task: TaskSwitcherItem, isDraggable: boolean) => React.ReactNode;
}) {
const { sensors, handleDragEnd, sortableIds } = useLevelDnd(tasks, onReorder);
const isDraggable = !!onReorder;
const body = tasks.map((task) => renderNode(task, isDraggable));
if (!onReorder) return <>{body}</>;
return (
<DndContext sensors={sensors} collisionDetection={closestCenter} onDragEnd={handleDragEnd}>
<SortableContext items={sortableIds} strategy={verticalListSortingStrategy}>
{body}
</SortableContext>
</DndContext>
);
}