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:
+32
-32
File diff suppressed because one or more lines are too long
Vendored
+1
-1
@@ -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>
|
||||
|
||||
@@ -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(() => {
|
||||
|
||||
@@ -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,
|
||||
});
|
||||
|
||||
|
||||
@@ -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"
|
||||
|
||||
Reference in New Issue
Block a user