mounts (VirtuosoSeed is used below the threshold and cannot
+ // reproduce the store-driven loop).
+ const issues = Array.from({ length: 40 }, (_, i) =>
+ makeIssue({ id: `b${i}`, title: `Board Card ${i}`, status: "todo", position: 100 + i }),
+ );
+
+ renderWithProviders(
+ ,
+ );
+
+ // Reaching a stable paint (column header visible) proves the render settled
+ // instead of looping.
+ await waitFor(() => {
+ expect(screen.getByText("Todo")).toBeInTheDocument();
+ });
+ expect(screen.getByText("Board Card 0")).toBeInTheDocument();
+ });
+
+ it("Swimlane grouped by assignee paints during cold load (real Virtuoso mounts, no update-depth loop)", async () => {
+ mockViewState.swimlaneGrouping = "assignee";
+ const issues = [
+ makeIssue({ id: "s1", title: "Swim Card 1", assignee_type: "member", assignee_id: "user-1", status: "todo" }),
+ makeIssue({ id: "s2", title: "Swim Card 2", assignee_type: "agent", assignee_id: "agent-1", status: "in_progress" }),
+ makeIssue({ id: "s3", title: "Swim Card 3", assignee_type: null, assignee_id: null, status: "todo" }),
+ ];
+
+ renderWithProviders(
+ ,
+ );
+
+ await waitFor(() => {
+ expect(screen.getByText("Swim Card 1")).toBeInTheDocument();
+ });
+ expect(screen.getByText("Swim Card 3")).toBeInTheDocument();
+ });
+});
diff --git a/packages/views/issues/components/use-drag-settle.test.tsx b/packages/views/issues/components/use-drag-settle.test.tsx
new file mode 100644
index 000000000..6eb7a1efb
--- /dev/null
+++ b/packages/views/issues/components/use-drag-settle.test.tsx
@@ -0,0 +1,53 @@
+/**
+ * @vitest-environment jsdom
+ */
+import { describe, it, expect } from "vitest";
+import { useEffect } from "react";
+import { render, act } from "@testing-library/react";
+import { useDragSettle } from "./use-drag-settle";
+
+describe("useDragSettle", () => {
+ // MUL-4985 defense-in-depth: the board/list/swimlane resync effect calls
+ // `setColumns(buildColumns(...))` whenever its `groups` input changes
+ // identity. When `groups` churns a fresh-but-content-equal value every render
+ // (the cold-load failure mode), an unguarded setter allocated a new column
+ // object each render and spun into "Maximum update depth exceeded". The
+ // equality guard returns the previous reference for a content-equal rebuild,
+ // so React bails and the loop cannot start.
+ it("does not loop when a resync effect rebuilds a content-equal column map every render", () => {
+ let renders = 0;
+ function Harness() {
+ renders++;
+ const { columns, setColumns } = useDragSettle(() => ({ todo: ["a", "b"] }));
+ // A fresh object of identical content on every render — what an unstable
+ // `groups` fed through buildColumns produces.
+ useEffect(() => {
+ setColumns({ todo: ["a", "b"] });
+ });
+ return {Object.keys(columns).join(",")}
;
+ }
+
+ expect(() => render()).not.toThrow();
+ // A guarded setter settles in one or two renders; an unguarded loop is in
+ // the hundreds before React throws. Bound generously to stay robust.
+ expect(renders).toBeLessThan(10);
+ });
+
+ it("still applies a content-changed column update", () => {
+ function Harness({ next }: { next: Record }) {
+ const { columns, setColumns } = useDragSettle(() => ({ todo: ["a"] }));
+ useEffect(() => {
+ setColumns(next);
+ }, [next, setColumns]);
+ return {(columns.todo ?? []).join(",")}
;
+ }
+
+ const { getByTestId, rerender } = render();
+ expect(getByTestId("cols").textContent).toBe("a");
+
+ act(() => {
+ rerender();
+ });
+ expect(getByTestId("cols").textContent).toBe("a,b");
+ });
+});
diff --git a/packages/views/issues/components/use-drag-settle.ts b/packages/views/issues/components/use-drag-settle.ts
index 01141b134..beca36d92 100644
--- a/packages/views/issues/components/use-drag-settle.ts
+++ b/packages/views/issues/components/use-drag-settle.ts
@@ -1,4 +1,36 @@
-import { useCallback, useEffect, useRef, useState } from "react";
+import {
+ useCallback,
+ useEffect,
+ useRef,
+ useState,
+ type Dispatch,
+ type SetStateAction,
+} from "react";
+
+/**
+ * Content equality for the column map (`columnId -> ordered issue ids`). Two
+ * maps are equal when they have the same column keys and each column's id list
+ * matches element-for-element. Used to skip no-op `setColumns` writes.
+ */
+function columnsEqual(
+ a: Record,
+ b: Record,
+): boolean {
+ if (a === b) return true;
+ const aKeys = Object.keys(a);
+ const bKeys = Object.keys(b);
+ if (aKeys.length !== bKeys.length) return false;
+ for (const key of aKeys) {
+ const av = a[key];
+ const bv = b[key];
+ if (av === bv) continue;
+ if (!av || !bv || av.length !== bv.length) return false;
+ for (let i = 0; i < av.length; i++) {
+ if (av[i] !== bv[i]) return false;
+ }
+ }
+ return true;
+}
/**
* Shared drag/settle state machine for the issue boards (board-view, list-view).
@@ -30,12 +62,34 @@ export function useDragSettle(
const recentlyMovedRef = useRef(false);
const [settleVersion, setSettleVersion] = useState(0);
- const [columns, setColumns] = useState>(
+ const [columns, setColumnsState] = useState>(
initialColumns,
);
const columnsRef = useRef(columns);
columnsRef.current = columns;
+ // Equality-guarded column setter. When a resync (or a no-op drag) produces a
+ // column map whose contents match the current one, return the SAME reference
+ // so React bails out of the re-render. Second line of defense against the
+ // cold-load update loop (MUL-4985): even if some input to `buildColumns`
+ // regains a per-render-unstable identity, a content-equal rebuild no longer
+ // forces a new state object and cannot spin the resync effect. It never
+ // changes the resulting value — only skips redundant writes — so drag/settle
+ // semantics are unchanged.
+ const setColumns = useCallback<
+ Dispatch>>
+ >((update) => {
+ setColumnsState((prev) => {
+ const next =
+ typeof update === "function"
+ ? (update as (p: Record) => Record)(
+ prev,
+ )
+ : update;
+ return columnsEqual(prev, next) ? prev : next;
+ });
+ }, []);
+
useEffect(() => {
const id = requestAnimationFrame(() => {
recentlyMovedRef.current = false;