Files
multica/packages/core/realtime/use-realtime-sync-comment.test.tsx
Bohan Jiang d3fac023c9 feat(issues): live-resort board/list on updated_at drift (MUL-5016) (#5671)
* feat(issues): live-resort board/list on updated_at drift (MUL-5016)

An open Kanban board / list sorted by "Updated date" did not re-sort in
real time when a card's updated_at advanced — it only picked up the new
order on the next fetch (navigation, invalidation, etc.).

Two triggers now re-sort live:

- comment:created: a new comment bumps the parent issue's updated_at
  server-side (MUL-5009), but the WS handler only invalidated the
  per-issue timeline cache. It now also invalidates loaded updated_at-
  sorted issue-list/board/flat keys via invalidateUpdatedAtSortedIssue
  Lists, so the commented card re-sorts (and an off-window card can
  surface). Only updated_at-sorted keys are touched.

- same-status field edit: applyIssueChange already reconciled flat
  windows on updated_at-sort drift but the bucketed board only marked
  stale on membership/status change. It now marks an updated_at-sorted
  bucketed key stale whenever the patch advances updated_at, mirroring
  the flat-window rule. Deferred to onSettle for mutations (WS flushes
  immediately), respecting the existing timing contract.

Extracted patchChangesAnyIssueField shared helper. Tests cover the
coordinator drift/targeting and the comment:created wiring.

Co-authored-by: multica-agent <github@multica.ai>

* fix(issues): cover assignee-grouped boards and property/metadata events in updated_at re-sort (MUL-5016)

Addresses Elon's review of #5671.

Must-fix 1: invalidateUpdatedAtSortedIssueLists missed assignee-grouped
boards. They fold the sort into their filter bag (issueAssigneeGroupsOptions
does `{ ...filter, ...sort }`) rather than a standalone sort key, so the old
bucketed+flat enumeration skipped them. Replace it with a single predicate
invalidateQueries over issueKeys.all(wsId) that matches any query key with an
object part carrying sort_by: "updated_at" — covering status boards, flat
tables, and assignee-grouped boards (workspace + My Issues) with one rule.

Must-fix 2: custom-property and metadata edits also bump issue.updated_at
server-side (SetIssuePropertyValue / DeleteIssuePropertyValue /
SetIssueMetadataKey / DeleteIssueMetadataKey) but flow through
issue_properties:changed / issue_metadata:changed, not applyIssueChange.
Their handlers patched cards in place and only invalidated property/My-Issues/
grouped windows, so an updated_at-sorted workspace board or flat table stayed
in the old order. Call invalidateUpdatedAtSortedIssueLists from both
onIssuePropertiesChanged and onIssueMetadataChanged. Both are committed-only
paths (WS event + mutation onSuccess); the optimistic leg still uses
patchIssueProperties, so no premature refetch.

Tests: grouped-board targeting in cache-coordinator; updated_at vs position
re-sort for onIssueMetadataChanged / onIssuePropertiesChanged (incl. the
optimistic leg not refetching) in ws-updaters.

Co-authored-by: multica-agent <github@multica.ai>

---------

Co-authored-by: Bohan-J <bohan@devv.ai>
Co-authored-by: multica-agent <github@multica.ai>
2026-07-20 16:03:01 +08:00

119 lines
3.7 KiB
TypeScript

/**
* @vitest-environment jsdom
*/
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
import { renderHook } from "@testing-library/react";
import type { ReactNode } from "react";
import { describe, expect, it, vi, beforeEach, afterEach } from "vitest";
import type { WSClient } from "../api/ws-client";
import { issueKeys, type IssueSortParam } from "../issues/queries";
import type { ListIssuesCache } from "../types";
import { useRealtimeSync, type RealtimeSyncStores } from "./use-realtime-sync";
vi.mock("../platform/workspace-storage", () => ({
getCurrentWsId: () => "ws-1",
getCurrentSlug: () => "test-ws",
}));
vi.mock("../paths", () => ({
useHasOnboarded: () => true,
resolvePostAuthDestination: () => "/",
}));
// Records every ws.on handler by event name so a test can fire one directly.
function createRecordingWs(): {
ws: WSClient;
handlers: Record<string, (p: unknown) => void>;
} {
const handlers: Record<string, (p: unknown) => void> = {};
const ws = {
on: vi.fn((event: string, handler: (p: unknown) => void) => {
handlers[event] = handler;
return () => {};
}),
onAny: vi.fn(() => () => {}),
onReconnect: vi.fn(() => () => {}),
} as unknown as WSClient;
return { ws, handlers };
}
function createStores(): RealtimeSyncStores {
return {
authStore: Object.assign(() => ({}), {
getState: () => ({ user: { id: "u1" } }),
subscribe: () => () => {},
setState: () => {},
destroy: () => {},
}),
} as unknown as RealtimeSyncStores;
}
function createWrapper(qc: QueryClient) {
return function Wrapper({ children }: { children: ReactNode }) {
return <QueryClientProvider client={qc}>{children}</QueryClientProvider>;
};
}
const updatedSort: IssueSortParam = {
sort_by: "updated_at",
sort_direction: "desc",
};
const positionSort: IssueSortParam = { sort_by: "position" };
const updatedBoardKey = issueKeys.listSorted("ws-1", updatedSort);
const positionBoardKey = issueKeys.listSorted("ws-1", positionSort);
function bucketed(): ListIssuesCache {
return { byStatus: { todo: { issues: [], total: 1 } } };
}
describe("useRealtimeSync — comment:created re-sorts Updated date lists", () => {
let qc: QueryClient;
beforeEach(() => {
qc = new QueryClient({ defaultOptions: { queries: { retry: false } } });
});
afterEach(() => {
qc.clear();
vi.clearAllMocks();
});
it("invalidates the updated_at-sorted board but leaves the position board", () => {
qc.setQueryData<ListIssuesCache>(updatedBoardKey, bucketed());
qc.setQueryData<ListIssuesCache>(positionBoardKey, bucketed());
qc.setQueryData(issueKeys.timeline("issue-1"), []);
const { ws, handlers } = createRecordingWs();
renderHook(() => useRealtimeSync(ws, createStores()), {
wrapper: createWrapper(qc),
});
handlers["comment:created"]?.({
comment: { id: "c1", issue_id: "issue-1" },
});
expect(qc.getQueryState(updatedBoardKey)?.isInvalidated).toBe(true);
expect(qc.getQueryState(positionBoardKey)?.isInvalidated).toBe(false);
// The per-issue timeline is still invalidated as before.
expect(
qc.getQueryState(issueKeys.timeline("issue-1"))?.isInvalidated,
).toBe(true);
});
it("ignores a comment event with no issue_id", () => {
qc.setQueryData<ListIssuesCache>(updatedBoardKey, bucketed());
const { handlers } = (() => {
const rec = createRecordingWs();
renderHook(() => useRealtimeSync(rec.ws, createStores()), {
wrapper: createWrapper(qc),
});
return rec;
})();
handlers["comment:created"]?.({ comment: { id: "c1" } });
expect(qc.getQueryState(updatedBoardKey)?.isInvalidated).toBe(false);
});
});