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 charset="UTF-8" />
|
||||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||||
<title>Kanban</title>
|
<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">
|
<link rel="stylesheet" crossorigin href="/assets/index-b0xjFtx2.css">
|
||||||
</head>
|
</head>
|
||||||
<body>
|
<body>
|
||||||
|
|||||||
@@ -300,10 +300,14 @@ export function App() {
|
|||||||
reloadRequesters();
|
reloadRequesters();
|
||||||
}, [reloadTags, 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(() => {
|
useEffect(() => {
|
||||||
|
if (activeCard || activeColumnId) return;
|
||||||
const t = setInterval(() => setNow(Date.now()), 1000);
|
const t = setInterval(() => setNow(Date.now()), 1000);
|
||||||
return () => clearInterval(t);
|
return () => clearInterval(t);
|
||||||
}, []);
|
}, [activeCard, activeColumnId]);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const t = setInterval(() => {
|
const t = setInterval(() => {
|
||||||
|
|||||||
@@ -32,7 +32,7 @@ import {
|
|||||||
IconUserCircle,
|
IconUserCircle,
|
||||||
} from "@tabler/icons-react";
|
} from "@tabler/icons-react";
|
||||||
import { DatePickerInput } from "@mantine/dates";
|
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 type { Card, CardColor, User } from "../types";
|
||||||
import { colorBg, colorBorder, tagColor } from "./colors";
|
import { colorBg, colorBorder, tagColor } from "./colors";
|
||||||
import { ColorPickerGrid } from "./ColorPickerGrid";
|
import { ColorPickerGrid } from "./ColorPickerGrid";
|
||||||
@@ -101,9 +101,17 @@ function KanbanCardImpl({
|
|||||||
const cardElRef = useRef<HTMLElement | null>(null);
|
const cardElRef = useRef<HTMLElement | null>(null);
|
||||||
const draggingStickerRef = useRef<number | null>(null);
|
const draggingStickerRef = useRef<number | null>(null);
|
||||||
const stickerMode = !!activeSticker;
|
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({
|
const { attributes, listeners, setNodeRef, transform, transition, isDragging } = useSortable({
|
||||||
id: card.id,
|
id: card.id,
|
||||||
data: { type: "card", columnId: card.column_id, locked: card.locked },
|
data: sortableData,
|
||||||
disabled: stickerMode,
|
disabled: stickerMode,
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
@@ -35,7 +35,7 @@ import {
|
|||||||
IconTrash,
|
IconTrash,
|
||||||
IconX,
|
IconX,
|
||||||
} from "@tabler/icons-react";
|
} 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 type { Card, CardColor, Column, User } from "../types";
|
||||||
import { KanbanCard } from "./KanbanCard";
|
import { KanbanCard } from "./KanbanCard";
|
||||||
|
|
||||||
@@ -54,6 +54,10 @@ const MAX_TIME_UNIT_LABEL: Record<MaxTimeUnit, string> = {
|
|||||||
weeks: "semanas",
|
weeks: "semanas",
|
||||||
months: "meses",
|
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 {
|
interface Props {
|
||||||
column: Column;
|
column: Column;
|
||||||
@@ -165,9 +169,13 @@ function KanbanColumnImpl({
|
|||||||
setLocalWidth(null);
|
setLocalWidth(null);
|
||||||
}, [column.width]);
|
}, [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({
|
const { attributes, listeners, setNodeRef, transform, transition, isDragging } = useSortable({
|
||||||
id: `column-${column.id}`,
|
id: `column-${column.id}`,
|
||||||
data: { type: "column", columnId: column.id, location: column.location },
|
data: sortableData,
|
||||||
});
|
});
|
||||||
|
|
||||||
const effectiveWidth = collapsed ? "100%" : localWidth ?? column.width;
|
const effectiveWidth = collapsed ? "100%" : localWidth ?? column.width;
|
||||||
@@ -493,10 +501,7 @@ function KanbanColumnImpl({
|
|||||||
size="xs"
|
size="xs"
|
||||||
value={maxTimeUnit}
|
value={maxTimeUnit}
|
||||||
onChange={(v) => v && setMaxTimeUnit(v as MaxTimeUnit)}
|
onChange={(v) => v && setMaxTimeUnit(v as MaxTimeUnit)}
|
||||||
data={(Object.keys(MAX_TIME_UNIT_LABEL) as MaxTimeUnit[]).map((u) => ({
|
data={MAX_TIME_UNIT_SELECT_DATA}
|
||||||
value: u,
|
|
||||||
label: MAX_TIME_UNIT_LABEL[u],
|
|
||||||
}))}
|
|
||||||
style={{ width: 130 }}
|
style={{ width: 130 }}
|
||||||
allowDeselect={false}
|
allowDeselect={false}
|
||||||
data-test="column-max-time-unit"
|
data-test="column-max-time-unit"
|
||||||
|
|||||||
Reference in New Issue
Block a user