mirror of
https://github.com/multica-ai/multica.git
synced 2026-07-26 20:45:37 +02:00
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>
99 lines
3.9 KiB
TypeScript
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");
|
|
});
|
|
});
|