Files
multica/packages/core/workspace/hooks.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

99 lines
3.9 KiB
TypeScript

/**
* @vitest-environment jsdom
*/
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import { renderHook } from "@testing-library/react";
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
import type { ReactNode } from "react";
import { setApiInstance } from "../api";
import type { ApiClient } from "../api/client";
import { workspaceKeys } from "./queries";
import { useActorName } from "./hooks";
// useActorName reads the current workspace from the core WorkspaceId provider;
// the directory-name resolution under test does not depend on the real id.
vi.mock("../hooks", () => ({
useWorkspaceId: () => "ws-1",
}));
function createWrapper(qc: QueryClient) {
return function Wrapper({ children }: { children: ReactNode }) {
return <QueryClientProvider client={qc}>{children}</QueryClientProvider>;
};
}
describe("useActorName", () => {
let qc: QueryClient;
beforeEach(() => {
qc = new QueryClient({ defaultOptions: { queries: { retry: false } } });
});
afterEach(() => {
qc.clear();
vi.restoreAllMocks();
});
// MUL-4985 regression: while the member/agent/squad directory queries are
// still loading, `data` is undefined. A `= []` default allocated a fresh
// array every render, so `getActorName` (memoized on those arrays) changed
// identity on every render. Consumers that list `getActorName` in their own
// memo deps (BoardView's `groups`, SwimLaneView's `laneGroups`) then churned
// a new value each render and spun the column-resync effect without end —
// an infinite re-render that react-virtuoso escalated into "Maximum update
// depth exceeded" on the Issues route. The fix shares one stable empty
// reference for the loading snapshot, so `getActorName` must be stable
// across re-renders while the directories are unresolved.
it("returns a referentially stable getActorName across renders during cold load", () => {
// Directory endpoints never resolve → the queries stay pending, so the
// hook renders repeatedly with undefined directory data (the cold-load
// state that used to loop).
const pending = () => new Promise<never>(() => {});
setApiInstance({
listMembers: pending,
listAgents: pending,
listSquads: pending,
} as unknown as ApiClient);
const { result, rerender } = renderHook(() => useActorName(), {
wrapper: createWrapper(qc),
});
const first = result.current.getActorName;
rerender();
const second = result.current.getActorName;
rerender();
const third = result.current.getActorName;
expect(second).toBe(first);
expect(third).toBe(first);
// A stable resolver over an empty directory still resolves gracefully.
expect(first("member", "user-1")).toBe("Unknown");
});
it("resolves names once the directories are loaded", () => {
// Seed the caches directly so the hook reads resolved directories on its
// first render — this guards that stabilizing the loading default did not
// break name resolution when data IS present.
const members = [{ user_id: "user-1", name: "Ada", avatar_url: null }];
const agents = [{ id: "agent-1", name: "Walt", avatar_url: null }];
const squads = [{ id: "squad-1", name: "Core", avatar_url: null }];
setApiInstance({
listMembers: () => Promise.resolve(members),
listAgents: () => Promise.resolve(agents),
listSquads: () => Promise.resolve(squads),
} as unknown as ApiClient);
qc.setQueryData(workspaceKeys.members("ws-1"), members);
qc.setQueryData(workspaceKeys.agents("ws-1"), agents);
qc.setQueryData(workspaceKeys.squads("ws-1"), squads);
const { result } = renderHook(() => useActorName(), {
wrapper: createWrapper(qc),
});
expect(result.current.getActorName("member", "user-1")).toBe("Ada");
expect(result.current.getActorName("agent", "agent-1")).toBe("Walt");
expect(result.current.getActorName("squad", "squad-1")).toBe("Core");
});
});