Files
multica/packages/views/issues/components/use-drag-settle.test.tsx
Naiyuan Qing fcb4798f12 fix(issues): stop cold-load render loop on the Issues route (MUL-4985) (#5643)
Board and Swimlane crashed with "Maximum update depth exceeded" on first
paint of the Issues route. During cold load the member/agent/squad
directory queries are unresolved, so useActorName's `= []` defaults
allocated a fresh array every render and its `getActorName` memo changed
identity each render. BoardView's `groups` (and SwimLaneView's
`laneGroups`) list `getActorName` as a dep, so they churned every render
and re-fired the column-resync effect without end; react-virtuoso
escalated the loop into the reported crash. This predates MUL-4797 — it
was exposed when board/swimlane columns were virtualized with a real
<Virtuoso>.

- Share stable empty references for the loading directory snapshot so
  getActorName is referentially stable across cold-load renders (root
  cause).
- Equality-guard the shared column setter in useDragSettle so a
  content-equal rebuild returns the previous reference and cannot spin
  the resync effect (defense-in-depth).

Regression coverage: useActorName stability during cold load; the
column-setter equality guard; and Board (40 cards) + Swimlane rendered
with the REAL react-virtuoso under pending directories, which reproduce
"Maximum update depth exceeded" without the fix and pass with it.

Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>
Co-authored-by: multica-agent <github@multica.ai>
2026-07-20 08:52:02 +08:00

54 lines
2.1 KiB
TypeScript

/**
* @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 <div>{Object.keys(columns).join(",")}</div>;
}
expect(() => render(<Harness />)).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<string, string[]> }) {
const { columns, setColumns } = useDragSettle(() => ({ todo: ["a"] }));
useEffect(() => {
setColumns(next);
}, [next, setColumns]);
return <div data-testid="cols">{(columns.todo ?? []).join(",")}</div>;
}
const { getByTestId, rerender } = render(<Harness next={{ todo: ["a"] }} />);
expect(getByTestId("cols").textContent).toBe("a");
act(() => {
rerender(<Harness next={{ todo: ["a", "b"] }} />);
});
expect(getByTestId("cols").textContent).toBe("a,b");
});
});