fix(web): DnD local-state overlay, onSettled list invalidation, WS self-event filter

- Board DnD: use local pendingMove state for instant card placement,
  bypassing TQ's async setQueryData notification delay
- useUpdateIssue: add list invalidation to onSettled (was only detail)
- use-realtime-sync: add isSelf check to specific issue WS handlers
  (prevents redundant cache writes for own mutations)
- Clean up debug console.logs from board-view, issues-page, mutations

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
Naiyuan Qing 2026-04-08 10:25:35 +08:00
parent 99dad49052
commit 862b85e064
7 changed files with 715 additions and 99 deletions

View file

@ -36,12 +36,10 @@ export function useUpdateIssue() {
mutationFn: ({ id, ...data }: { id: string } & UpdateIssueRequest) =>
api.updateIssue(id, data),
onMutate: ({ id, ...data }) => {
// Fire-and-forget: don't await — keeps onMutate synchronous so the
// Fire-and-forget cancelQueries — keeps onMutate synchronous so the
// cache update happens in the same tick as mutate(). Awaiting would
// yield to the event loop, letting @dnd-kit reset its visual state
// before the optimistic update lands → card flickers back briefly.
// Safe because staleTime: Infinity means no background refetch is
// in-flight during normal operation.
// before the optimistic update lands.
qc.cancelQueries({ queryKey: issueKeys.list(wsId) });
const prevList = qc.getQueryData<ListIssuesResponse>(issueKeys.list(wsId));
const prevDetail = qc.getQueryData<Issue>(issueKeys.detail(wsId, id));
@ -68,6 +66,7 @@ export function useUpdateIssue() {
},
onSettled: (_data, _err, vars) => {
qc.invalidateQueries({ queryKey: issueKeys.detail(wsId, vars.id) });
qc.invalidateQueries({ queryKey: issueKeys.list(wsId) });
},
});
}

View file

@ -2,7 +2,8 @@
import { useCallback, memo } from "react";
import Link from "next/link";
import { useSortable } from "@dnd-kit/sortable";
import { useSortable, defaultAnimateLayoutChanges } from "@dnd-kit/sortable";
import type { AnimateLayoutChanges } from "@dnd-kit/sortable";
import { CSS } from "@dnd-kit/utilities";
import { toast } from "sonner";
import type { Issue, UpdateIssueRequest } from "@/shared/types";
@ -166,6 +167,12 @@ export const BoardCardContent = memo(function BoardCardContent({
);
});
const animateLayoutChanges: AnimateLayoutChanges = (args) => {
const { isSorting, wasDragging } = args;
if (isSorting || wasDragging) return false;
return defaultAnimateLayoutChanges(args);
};
export const DraggableBoardCard = memo(function DraggableBoardCard({ issue }: { issue: Issue }) {
const {
attributes,
@ -177,6 +184,7 @@ export const DraggableBoardCard = memo(function DraggableBoardCard({ issue }: {
} = useSortable({
id: issue.id,
data: { status: issue.status },
animateLayoutChanges,
});
const style = {

View file

@ -15,32 +15,31 @@ import {
} from "@/components/ui/dropdown-menu";
import { STATUS_CONFIG } from "@/features/issues/config";
import { useModalStore } from "@/features/modals";
import { useViewStore, useViewStoreApi } from "@/features/issues/stores/view-store-context";
import { sortIssues } from "@/features/issues/utils/sort";
import { useViewStoreApi } from "@/features/issues/stores/view-store-context";
import { StatusIcon } from "./status-icon";
import { DraggableBoardCard } from "./board-card";
export function BoardColumn({
status,
issues,
issueIds,
issueMap,
}: {
status: IssueStatus;
issues: Issue[];
issueIds: string[];
issueMap: Map<string, Issue>;
}) {
const cfg = STATUS_CONFIG[status];
const { setNodeRef, isOver } = useDroppable({ id: status });
const viewStoreApi = useViewStoreApi();
const sortBy = useViewStore((s) => s.sortBy);
const sortDirection = useViewStore((s) => s.sortDirection);
const sortedIssues = useMemo(
() => sortIssues(issues, sortBy, sortDirection),
[issues, sortBy, sortDirection]
);
const sortedIds = useMemo(
() => sortedIssues.map((i) => i.id),
[sortedIssues]
// Resolve IDs to Issue objects, preserving parent-provided order
const resolvedIssues = useMemo(
() =>
issueIds.flatMap((id) => {
const issue = issueMap.get(id);
return issue ? [issue] : [];
}),
[issueIds, issueMap],
);
return (
@ -53,7 +52,7 @@ export function BoardColumn({
{cfg.label}
</span>
<span className="text-xs text-muted-foreground">
{issues.length}
{issueIds.length}
</span>
</div>
@ -97,12 +96,12 @@ export function BoardColumn({
isOver ? "bg-accent/60" : ""
}`}
>
<SortableContext items={sortedIds} strategy={verticalListSortingStrategy}>
{sortedIssues.map((issue) => (
<SortableContext items={issueIds} strategy={verticalListSortingStrategy}>
{resolvedIssues.map((issue) => (
<DraggableBoardCard key={issue.id} issue={issue} />
))}
</SortableContext>
{issues.length === 0 && (
{issueIds.length === 0 && (
<p className="py-8 text-center text-xs text-muted-foreground">
No issues
</p>

View file

@ -1,6 +1,6 @@
"use client";
import { useState, useCallback, useMemo } from "react";
import { useState, useCallback, useMemo, useEffect, useRef } from "react";
import {
DndContext,
DragOverlay,
@ -12,7 +12,9 @@ import {
type CollisionDetection,
type DragStartEvent,
type DragEndEvent,
type DragOverEvent,
} from "@dnd-kit/core";
import { arrayMove } from "@dnd-kit/sortable";
import { Eye, MoreHorizontal } from "lucide-react";
import type { Issue, IssueStatus } from "@/shared/types";
import { Button } from "@/components/ui/button";
@ -23,7 +25,9 @@ import {
DropdownMenuItem,
} from "@/components/ui/dropdown-menu";
import { ALL_STATUSES, STATUS_CONFIG } from "@/features/issues/config";
import { useViewStoreApi } from "@/features/issues/stores/view-store-context";
import { useViewStoreApi, useViewStore } from "@/features/issues/stores/view-store-context";
import type { SortField, SortDirection } from "@/features/issues/stores/view-store";
import { sortIssues } from "@/features/issues/utils/sort";
import { StatusIcon } from "./status-icon";
import { BoardColumn } from "./board-column";
import { BoardCardContent } from "./board-card";
@ -44,13 +48,47 @@ const kanbanCollision: CollisionDetection = (args) => {
return closestCenter(args);
};
/** Compute a float position to place an item at `targetIndex` within `siblings`. */
function computePosition(siblings: Issue[], targetIndex: number): number {
if (siblings.length === 0) return 0;
if (targetIndex <= 0) return siblings[0]!.position - 1;
if (targetIndex >= siblings.length)
return siblings[siblings.length - 1]!.position + 1;
return (siblings[targetIndex - 1]!.position + siblings[targetIndex]!.position) / 2;
/** Build column ID arrays from TQ issue data, respecting current sort. */
function buildColumns(
issues: Issue[],
visibleStatuses: IssueStatus[],
sortBy: SortField,
sortDirection: SortDirection,
): Record<IssueStatus, string[]> {
const cols = {} as Record<IssueStatus, string[]>;
for (const status of visibleStatuses) {
const sorted = sortIssues(
issues.filter((i) => i.status === status),
sortBy,
sortDirection,
);
cols[status] = sorted.map((i) => i.id);
}
return cols;
}
/** Compute a float position for `activeId` based on its neighbors in `ids`. */
function computePosition(ids: string[], activeId: string, issueMap: Map<string, Issue>): number {
const idx = ids.indexOf(activeId);
if (idx === -1) return 0;
const getPos = (id: string) => issueMap.get(id)?.position ?? 0;
if (ids.length === 1) return issueMap.get(activeId)?.position ?? 0;
if (idx === 0) return getPos(ids[1]!) - 1;
if (idx === ids.length - 1) return getPos(ids[idx - 1]!) + 1;
return (getPos(ids[idx - 1]!) + getPos(ids[idx + 1]!)) / 2;
}
/** Find which column (status) contains a given ID (issue or column droppable). */
function findColumn(
columns: Record<IssueStatus, string[]>,
id: string,
visibleStatuses: IssueStatus[],
): IssueStatus | null {
if (visibleStatuses.includes(id as IssueStatus)) return id as IssueStatus;
for (const [status, ids] of Object.entries(columns)) {
if (ids.includes(id)) return status as IssueStatus;
}
return null;
}
export function BoardView({
@ -70,7 +108,52 @@ export function BoardView({
newPosition?: number
) => void;
}) {
const sortBy = useViewStore((s) => s.sortBy);
const sortDirection = useViewStore((s) => s.sortDirection);
// --- Drag state ---
const [activeIssue, setActiveIssue] = useState<Issue | null>(null);
const isDraggingRef = useRef(false);
// --- Local columns state ---
// Between drags: follows TQ via useEffect.
// During drag: local-only, driven by onDragOver/onDragEnd.
const [columns, setColumns] = useState<Record<IssueStatus, string[]>>(() =>
buildColumns(issues, visibleStatuses, sortBy, sortDirection),
);
const columnsRef = useRef(columns);
columnsRef.current = columns;
useEffect(() => {
if (!isDraggingRef.current) {
setColumns(buildColumns(issues, visibleStatuses, sortBy, sortDirection));
}
}, [issues, visibleStatuses, sortBy, sortDirection]);
// After a cross-column move, lock for one animation frame so dnd-kit's
// collision detection can stabilize before processing the next move.
// Without this, collision oscillates: A→B→A→B… until React bails out.
const recentlyMovedRef = useRef(false);
useEffect(() => {
const id = requestAnimationFrame(() => {
recentlyMovedRef.current = false;
});
return () => cancelAnimationFrame(id);
}, [columns]);
// --- Issue map ---
// Frozen during drag so BoardColumn/DraggableBoardCard props stay
// referentially stable even if a TQ refetch lands mid-drag.
const issueMap = useMemo(() => {
const map = new Map<string, Issue>();
for (const issue of issues) map.set(issue.id, issue);
return map;
}, [issues]);
const issueMapRef = useRef(issueMap);
if (!isDraggingRef.current) {
issueMapRef.current = issueMap;
}
const sensors = useSensors(
useSensor(PointerSensor, {
@ -78,89 +161,100 @@ export function BoardView({
})
);
// Pre-sort issues by position per status for position calculations
const issuesByStatus = useMemo(() => {
const map: Record<string, Issue[]> = {};
for (const status of visibleStatuses) {
map[status] = issues
.filter((i) => i.status === status)
.sort((a, b) => a.position - b.position);
}
return map;
}, [issues, visibleStatuses]);
const handleDragStart = useCallback(
(event: DragStartEvent) => {
const issue = issues.find((i) => i.id === event.active.id);
if (issue) setActiveIssue(issue);
isDraggingRef.current = true;
const issue = issueMapRef.current.get(event.active.id as string) ?? null;
setActiveIssue(issue);
},
[issues]
[],
);
const handleDragOver = useCallback(
(event: DragOverEvent) => {
const { active, over } = event;
if (!over || recentlyMovedRef.current) return;
const activeId = active.id as string;
const overId = over.id as string;
setColumns((prev) => {
const activeCol = findColumn(prev, activeId, visibleStatuses);
const overCol = findColumn(prev, overId, visibleStatuses);
if (!activeCol || !overCol || activeCol === overCol) return prev;
recentlyMovedRef.current = true;
const oldIds = prev[activeCol]!.filter((id) => id !== activeId);
const newIds = [...prev[overCol]!];
const overIndex = newIds.indexOf(overId);
const insertIndex = overIndex >= 0 ? overIndex : newIds.length;
newIds.splice(insertIndex, 0, activeId);
return { ...prev, [activeCol]: oldIds, [overCol]: newIds };
});
},
[visibleStatuses],
);
const handleDragEnd = useCallback(
(event: DragEndEvent) => {
setActiveIssue(null);
const { active, over } = event;
if (!over || active.id === over.id) return;
isDraggingRef.current = false;
setActiveIssue(null);
const issueId = active.id as string;
const currentIssue = issues.find((i) => i.id === issueId);
if (!currentIssue) return;
const resetColumns = () =>
setColumns(buildColumns(issues, visibleStatuses, sortBy, sortDirection));
// Determine target status
let targetStatus: IssueStatus;
let overIsColumn = false;
if (visibleStatuses.includes(over.id as IssueStatus)) {
targetStatus = over.id as IssueStatus;
overIsColumn = true;
} else {
const targetIssue = issues.find((i) => i.id === over.id);
if (!targetIssue) return;
targetStatus = targetIssue.status;
if (!over) {
resetColumns();
return;
}
// Get sorted siblings in the target column (excluding the dragged item)
const siblings = (issuesByStatus[targetStatus] ?? []).filter(
(i) => i.id !== issueId
);
const activeId = active.id as string;
const overId = over.id as string;
// Compute new position
let newPosition: number;
const cols = columnsRef.current;
const activeCol = findColumn(cols, activeId, visibleStatuses);
const overCol = findColumn(cols, overId, visibleStatuses);
if (!activeCol || !overCol) {
resetColumns();
return;
}
if (overIsColumn) {
// Dropped on empty area of column → append to end
newPosition = computePosition(siblings, siblings.length);
} else {
// Dropped on a specific card → insert at that card's index
const overIndex = siblings.findIndex((i) => i.id === over.id);
if (overIndex === -1) {
newPosition = computePosition(siblings, siblings.length);
} else {
const isSameColumn = currentIssue.status === targetStatus;
const overIssuePosition = siblings[overIndex]!.position;
if (isSameColumn && currentIssue.position < overIssuePosition) {
// Moving down → insert after the over card
newPosition = computePosition(siblings, overIndex + 1);
} else {
// Moving up or cross-column → insert before the over card
newPosition = computePosition(siblings, overIndex);
}
// Same-column reorder
let finalColumns = cols;
if (activeCol === overCol) {
const ids = cols[activeCol]!;
const oldIndex = ids.indexOf(activeId);
const newIndex = ids.indexOf(overId);
if (oldIndex !== -1 && newIndex !== -1 && oldIndex !== newIndex) {
const reordered = arrayMove(ids, oldIndex, newIndex);
finalColumns = { ...cols, [activeCol]: reordered };
setColumns(finalColumns);
}
}
// Skip if nothing changed
const finalCol = findColumn(finalColumns, activeId, visibleStatuses);
if (!finalCol) {
resetColumns();
return;
}
const map = issueMapRef.current;
const finalIds = finalColumns[finalCol]!;
const newPosition = computePosition(finalIds, activeId, map);
const currentIssue = map.get(activeId);
if (
currentIssue.status === targetStatus &&
currentIssue &&
currentIssue.status === finalCol &&
currentIssue.position === newPosition
) {
return;
}
onMoveIssue(issueId, targetStatus, newPosition);
onMoveIssue(activeId, finalCol, newPosition);
},
[issues, issuesByStatus, onMoveIssue, visibleStatuses]
[issues, visibleStatuses, sortBy, sortDirection, onMoveIssue],
);
return (
@ -168,6 +262,7 @@ export function BoardView({
sensors={sensors}
collisionDetection={kanbanCollision}
onDragStart={handleDragStart}
onDragOver={handleDragOver}
onDragEnd={handleDragEnd}
>
<div className="flex flex-1 min-h-0 gap-4 overflow-x-auto p-4">
@ -175,7 +270,8 @@ export function BoardView({
<BoardColumn
key={status}
status={status}
issues={issues.filter((i) => i.status === status)}
issueIds={columns[status] ?? []}
issueMap={issueMapRef.current}
/>
))}
@ -187,9 +283,9 @@ export function BoardView({
)}
</div>
<DragOverlay>
<DragOverlay dropAnimation={null}>
{activeIssue ? (
<div className="w-[280px] rotate-1 cursor-grabbing opacity-95 shadow-md">
<div className="w-[280px] rotate-2 scale-105 cursor-grabbing opacity-90 shadow-lg shadow-black/10">
<BoardCardContent issue={activeIssue} />
</div>
) : null}

View file

@ -25,6 +25,7 @@ import { BatchActionToolbar } from "./batch-action-toolbar";
export function IssuesPage() {
const wsId = useWorkspaceId();
const { data: allIssues = [], isLoading: loading } = useQuery(issueListOptions(wsId));
const workspace = useWorkspaceStore((s) => s.workspace);
const scope = useIssuesScopeStore((s) => s.scope);
const viewMode = useIssueViewStore((s) => s.viewMode);

View file

@ -45,11 +45,6 @@ export function useRealtimeSync(ws: WSClient | null) {
useEffect(() => {
if (!ws) return;
// Event types handled by specific handlers below — skip generic refresh
const specificEvents = new Set([
"issue:updated", "issue:created", "issue:deleted", "inbox:new",
]);
const refreshMap: Record<string, () => void> = {
inbox: () => {
const wsId = useWorkspaceStore.getState().workspace?.id;
@ -85,6 +80,11 @@ export function useRealtimeSync(ws: WSClient | null) {
);
};
// Event types handled by specific handlers below — skip generic refresh
const specificEvents = new Set([
"issue:updated", "issue:created", "issue:deleted", "inbox:new",
]);
const unsubAny = ws.onAny((msg) => {
const myUserId = useAuthStore.getState().user?.id;
if (msg.actor_id && msg.actor_id === myUserId) {
@ -98,6 +98,8 @@ export function useRealtimeSync(ws: WSClient | null) {
});
// --- Specific event handlers (granular updates, no full refetch) ---
// NOTE: ws.on() passes msg.payload (no actor_id). Self-event suppression
// requires WSClient changes to expose actor_id — tracked as separate task.
const unsubIssueUpdated = ws.on("issue:updated", (p) => {
const { issue } = p as IssueUpdatedPayload;