fix(kanban): infinite ref-merge loop + drag lag

Two issues:

1. "Maximum update depth exceeded" inside Mantine's useMergedRef during
   drag. Root cause: the `data` object passed to dnd-kit's `useSortable`
   in KanbanCard and KanbanColumn was re-created on every render. Mantine
   Paper composes its internal ref with the consumer ref via
   useMergedRef, which uses a useCallback whose deps array contains the
   refs themselves. Whenever the underlying setNodeRef from useSortable
   became unstable (because dnd-kit's internal state churned on data
   identity change), the merged ref was reassigned each commit -> setState
   inside the ref callback -> next render -> new data identity -> loop.
   Wrap the sortable data in useMemo keyed on its real inputs.

2. Drag feels laggy. Each card listens to a 1-second `now` clock that
   re-renders the entire board to refresh the "time in column" label.
   Pause that interval while a drag is active so dnd-kit's per-pixel
   reconciliation does not also re-mount/re-layout every card every
   second. Tick resumes the moment the drag ends.

Also move the Select `data` array for the column max-time popover from
an inline expression to a module-level constant; same array identity
across all column instances and renders -> Mantine Combobox stops
re-running its diffing effect for free.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-05-14 14:12:06 +02:00
parent 76d85959f1
commit 7ba18f9114
5 changed files with 59 additions and 42 deletions
File diff suppressed because one or more lines are too long
+1 -1
View File
@@ -4,7 +4,7 @@
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>Kanban</title>
<script type="module" crossorigin src="/assets/index-D3fjM31T.js"></script>
<script type="module" crossorigin src="/assets/index-DfDr7FVp.js"></script>
<link rel="stylesheet" crossorigin href="/assets/index-b0xjFtx2.css">
</head>
<body>
+5 -1
View File
@@ -300,10 +300,14 @@ export function App() {
reloadRequesters();
}, [reloadTags, reloadRequesters]);
// Tick de reloj para "tiempo en columna" en cards. Pausamos durante drag
// porque dispara re-render de TODAS las cards cada segundo y el drag de
// dnd-kit sufre tirones serios con muchos elementos.
useEffect(() => {
if (activeCard || activeColumnId) return;
const t = setInterval(() => setNow(Date.now()), 1000);
return () => clearInterval(t);
}, []);
}, [activeCard, activeColumnId]);
useEffect(() => {
const t = setInterval(() => {
+10 -2
View File
@@ -32,7 +32,7 @@ import {
IconUserCircle,
} from "@tabler/icons-react";
import { DatePickerInput } from "@mantine/dates";
import { memo, useCallback, useEffect, useRef, useState } from "react";
import { memo, useCallback, useEffect, useMemo, useRef, useState } from "react";
import type { Card, CardColor, User } from "../types";
import { colorBg, colorBorder, tagColor } from "./colors";
import { ColorPickerGrid } from "./ColorPickerGrid";
@@ -101,9 +101,17 @@ function KanbanCardImpl({
const cardElRef = useRef<HTMLElement | null>(null);
const draggingStickerRef = useRef<number | null>(null);
const stickerMode = !!activeSticker;
// Memo: useSortable es sensible a la identidad del objeto `data`. Si lo
// re-creamos cada render, el setNodeRef interno se vuelve inestable y
// dispara loops por useMergedRef de Mantine (Paper). Issue: maximum
// update depth visto durante drag.
const sortableData = useMemo(
() => ({ type: "card" as const, columnId: card.column_id, locked: card.locked }),
[card.column_id, card.locked]
);
const { attributes, listeners, setNodeRef, transform, transition, isDragging } = useSortable({
id: card.id,
data: { type: "card", columnId: card.column_id, locked: card.locked },
data: sortableData,
disabled: stickerMode,
});
+11 -6
View File
@@ -35,7 +35,7 @@ import {
IconTrash,
IconX,
} from "@tabler/icons-react";
import { memo, MouseEvent, useEffect, useRef, useState } from "react";
import { memo, MouseEvent, useEffect, useMemo, useRef, useState } from "react";
import type { Card, CardColor, Column, User } from "../types";
import { KanbanCard } from "./KanbanCard";
@@ -54,6 +54,10 @@ const MAX_TIME_UNIT_LABEL: Record<MaxTimeUnit, string> = {
weeks: "semanas",
months: "meses",
};
const MAX_TIME_UNIT_SELECT_DATA = (Object.keys(MAX_TIME_UNIT_LABEL) as MaxTimeUnit[]).map((u) => ({
value: u,
label: MAX_TIME_UNIT_LABEL[u],
}));
interface Props {
column: Column;
@@ -165,9 +169,13 @@ function KanbanColumnImpl({
setLocalWidth(null);
}, [column.width]);
const sortableData = useMemo(
() => ({ type: "column" as const, columnId: column.id, location: column.location }),
[column.id, column.location]
);
const { attributes, listeners, setNodeRef, transform, transition, isDragging } = useSortable({
id: `column-${column.id}`,
data: { type: "column", columnId: column.id, location: column.location },
data: sortableData,
});
const effectiveWidth = collapsed ? "100%" : localWidth ?? column.width;
@@ -493,10 +501,7 @@ function KanbanColumnImpl({
size="xs"
value={maxTimeUnit}
onChange={(v) => v && setMaxTimeUnit(v as MaxTimeUnit)}
data={(Object.keys(MAX_TIME_UNIT_LABEL) as MaxTimeUnit[]).map((u) => ({
value: u,
label: MAX_TIME_UNIT_LABEL[u],
}))}
data={MAX_TIME_UNIT_SELECT_DATA}
style={{ width: 130 }}
allowDeselect={false}
data-test="column-max-time-unit"