mirror of
https://github.com/multica-ai/multica.git
synced 2026-07-26 04:25:46 +02:00
The per-project issue list rides the filtered myAll cache. Changing an issue's project is a membership change, but the surgical patch (patchIssueInBuckets) is filter-blind and never removes a card that no longer matches the list's project filter — so a moved issue stayed visible in the old project's list until a manual refetch (#4548 / MUL-3669). Root cause: project_id was the only membership-affecting field with no server *_changed flag. The WS handler fell back to diffing project_id against its own cache, which breaks once onMutate has optimistically overwritten the cached value on a local move. - server: stamp project_changed on issue:updated (UpdateIssue + Batch), alongside status_changed / assignee_changed. - events.ts: surface project_changed (optional, additive — old clients ignore). - ws-updaters: prefer the server flag, fall back to the cache diff only when absent (older backend) so a new frontend on an old backend does not regress. - mutations: onSettled invalidates myAll when project_id changed — a local safety net that never depends on the WS echo (update + batch). Tests: WS flag wins over a matching cache (local-move repro), explicit false suppresses the legacy diff, the cache-diff fallback still fires, and both mutations invalidate myAll on a project change. Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
246 lines
11 KiB
TypeScript
246 lines
11 KiB
TypeScript
import type { QueryClient, QueryKey } from "@tanstack/react-query";
|
|
import { issueKeys } from "./queries";
|
|
import { labelKeys } from "../labels/queries";
|
|
import { projectKeys } from "../projects/queries";
|
|
import {
|
|
addIssueToBuckets,
|
|
findIssueLocation,
|
|
patchIssueInBuckets,
|
|
} from "./cache-helpers";
|
|
import { cleanupDeletedIssueCaches } from "./delete-cache";
|
|
import type { Issue, IssueLabelsResponse, IssueMetadata, Label } from "../types";
|
|
import type { ListIssuesCache } from "../types";
|
|
|
|
export function onIssueCreated(
|
|
qc: QueryClient,
|
|
wsId: string,
|
|
issue: Issue,
|
|
) {
|
|
for (const [key, data] of qc.getQueriesData<ListIssuesCache>({ queryKey: issueKeys.list(wsId) })) {
|
|
if (data) qc.setQueryData<ListIssuesCache>(key, addIssueToBuckets(data, issue));
|
|
}
|
|
qc.invalidateQueries({ queryKey: issueKeys.myAll(wsId) });
|
|
qc.invalidateQueries({ queryKey: issueKeys.assigneeGroupsAll(wsId) });
|
|
qc.invalidateQueries({ queryKey: issueKeys.myAssigneeGroupsAll(wsId) });
|
|
if (issue.project_id) {
|
|
qc.invalidateQueries({ queryKey: projectKeys.all(wsId) });
|
|
}
|
|
// Refresh every Project Gantt cache that might be observing this issue.
|
|
// We invalidate the whole prefix rather than the issue's own project
|
|
// because a fresh issue isn't necessarily scheduled yet; the active Gantt
|
|
// page (if any) will refetch and pick it up if it qualifies.
|
|
qc.invalidateQueries({ queryKey: issueKeys.projectGanttAll(wsId) });
|
|
if (issue.parent_issue_id) {
|
|
qc.invalidateQueries({ queryKey: issueKeys.children(wsId, issue.parent_issue_id) });
|
|
qc.invalidateQueries({ queryKey: issueKeys.childProgress(wsId) });
|
|
}
|
|
}
|
|
|
|
export function onIssueUpdated(
|
|
qc: QueryClient,
|
|
wsId: string,
|
|
issue: Partial<Issue> & { id: string },
|
|
// assigneeChanged / statusChanged / projectChanged come from the server's
|
|
// issue:updated flags. assigneeChanged + projectChanged gate the filtered-list
|
|
// (myAll) invalidate so a non-membership change keeps those lists in place
|
|
// instead of refetching. statusChanged gates the off-screen count reconcile
|
|
// below.
|
|
meta: {
|
|
assigneeChanged?: boolean;
|
|
statusChanged?: boolean;
|
|
projectChanged?: boolean;
|
|
} = {},
|
|
) {
|
|
// Look up the OLD parent before mutating list state, so we can keep
|
|
// the parent's children cache in sync (powers the sub-issues list
|
|
// shown on the parent issue page).
|
|
const listQueries = qc.getQueriesData<ListIssuesCache>({ queryKey: issueKeys.list(wsId) });
|
|
const firstListData = listQueries[0]?.[1];
|
|
const detailData = qc.getQueryData<Issue>(issueKeys.detail(wsId, issue.id));
|
|
const oldParentId =
|
|
detailData?.parent_issue_id ??
|
|
(firstListData ? findIssueLocation(firstListData, issue.id)?.issue.parent_issue_id : null) ??
|
|
null;
|
|
// The NEW parent comes from the WS payload when parent_issue_id changed
|
|
const newParentId = issue.parent_issue_id ?? null;
|
|
const parentChanged =
|
|
issue.parent_issue_id !== undefined && newParentId !== oldParentId;
|
|
|
|
// Project board membership keys on project_id. Prefer the server's
|
|
// project_changed flag (authoritative, set on the wire). Fall back to diffing
|
|
// the incoming project_id against the cached one only when the flag is absent
|
|
// (older backend): the diff is unreliable once a local optimistic move has
|
|
// overwritten the cached project_id, but it still covers remote/agent moves
|
|
// and keeps a new frontend on an old backend from regressing (MUL-3669 /
|
|
// #4548). The local move itself is also covered by the onSettled safety net in
|
|
// useUpdateIssue, which never depends on this flag.
|
|
const oldProjectId =
|
|
detailData?.project_id ??
|
|
(firstListData ? findIssueLocation(firstListData, issue.id)?.issue.project_id : null) ??
|
|
null;
|
|
const projectChanged =
|
|
meta.projectChanged ??
|
|
(issue.project_id !== undefined && (issue.project_id ?? null) !== oldProjectId);
|
|
|
|
// A status change shifts two bucket totals (the column header counts).
|
|
// patchIssueInBuckets does that surgically, but only when it can find the card
|
|
// in a loaded page; a paginated column holds just its first page, so an issue
|
|
// outside that window — common when an agent flips the status of something the
|
|
// viewer never scrolled to — makes the patch a no-op (it returns the same
|
|
// reference) and the totals silently drift. A status change otherwise never
|
|
// refetches the list (that refetch was the drag flicker removed by the
|
|
// optimistic-update work), so recover the one case the patch cannot: on a
|
|
// status-changed no-op, refetch just that single list to reconcile its counts.
|
|
const patchOrRefetchCounts = (key: QueryKey, data: ListIssuesCache) => {
|
|
const next = patchIssueInBuckets(data, issue.id, issue);
|
|
qc.setQueryData<ListIssuesCache>(key, next);
|
|
if (next === data && meta.statusChanged) {
|
|
qc.invalidateQueries({ queryKey: key });
|
|
}
|
|
};
|
|
|
|
for (const [key, data] of listQueries) {
|
|
if (data) patchOrRefetchCounts(key, data);
|
|
}
|
|
// The workspace board (issueKeys.list) is NOT filtered: an issue is always a
|
|
// member, so patchIssueInBuckets above is a complete surgical reconcile —
|
|
// cross-status move, same-column reorder, and field updates all land in the
|
|
// right bucket/slot. The old `if (position) invalidateQueries(list)` re-pulled
|
|
// the entire board on top of that, which is the full-list refetch that made a
|
|
// drag (local or echoed back over WS) flicker. It is pure redundancy here.
|
|
//
|
|
// myAll (My Issues / Project / actor lists) IS filtered. Surgically patch the
|
|
// cards that already live in those caches too, so a non-membership change
|
|
// (pure status / position / priority / label) reconciles in place — no
|
|
// refetch, no flicker — exactly like the workspace board above.
|
|
const myListQueries = qc.getQueriesData<ListIssuesCache>({ queryKey: issueKeys.myAll(wsId) });
|
|
for (const [key, data] of myListQueries) {
|
|
if (data?.byStatus) patchOrRefetchCounts(key, data);
|
|
}
|
|
// Only refetch the filtered lists when the change can actually move an issue
|
|
// in/out of one. My-Issues / actor-panel membership keys on the assignee (the
|
|
// "involves" leg — my agents / my squads — is assignee-based too), so the
|
|
// server's assignee_changed flag covers it; the Project board keys on
|
|
// project_id. A pure status / position / priority / label change cannot change
|
|
// membership, so the surgical patch above is the complete reconcile and we
|
|
// skip the invalidate that used to make a My-Issues drag refetch + flicker.
|
|
if (meta.assigneeChanged || projectChanged) {
|
|
qc.invalidateQueries({ queryKey: issueKeys.myAll(wsId) });
|
|
}
|
|
qc.invalidateQueries({ queryKey: issueKeys.assigneeGroupsAll(wsId) });
|
|
qc.invalidateQueries({ queryKey: issueKeys.myAssigneeGroupsAll(wsId) });
|
|
if (issue.status !== undefined || issue.project_id !== undefined) {
|
|
qc.invalidateQueries({ queryKey: projectKeys.all(wsId) });
|
|
}
|
|
// Any field change can shift Gantt membership — start_date / due_date may
|
|
// have moved in or out of the `scheduled` set, project_id may have
|
|
// changed, or the row that is in the cache may need to mirror updated
|
|
// metadata (title, status, assignee). Cheaper to invalidate the prefix
|
|
// than to mirror the server filter here.
|
|
qc.invalidateQueries({ queryKey: issueKeys.projectGanttAll(wsId) });
|
|
qc.setQueryData<Issue>(issueKeys.detail(wsId, issue.id), (old) =>
|
|
old ? { ...old, ...issue } : old,
|
|
);
|
|
|
|
// Invalidate old parent's children (issue was removed from it)
|
|
if (oldParentId) {
|
|
if (parentChanged) {
|
|
qc.invalidateQueries({ queryKey: issueKeys.children(wsId, oldParentId) });
|
|
} else {
|
|
qc.setQueryData<Issue[]>(issueKeys.children(wsId, oldParentId), (old) =>
|
|
old?.map((c) => (c.id === issue.id ? { ...c, ...issue } : c)),
|
|
);
|
|
}
|
|
}
|
|
// Invalidate new parent's children (issue was added to it)
|
|
if (newParentId && parentChanged) {
|
|
qc.invalidateQueries({ queryKey: issueKeys.children(wsId, newParentId) });
|
|
}
|
|
if (oldParentId || newParentId) {
|
|
if (issue.status !== undefined || issue.parent_issue_id !== undefined) {
|
|
qc.invalidateQueries({ queryKey: issueKeys.childProgress(wsId) });
|
|
}
|
|
qc.invalidateQueries({ queryKey: issueKeys.childrenByParentsAll(wsId) });
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Patch an issue's labels in-place across the list cache, my-issues caches,
|
|
* the detail cache, and the per-issue label cache. Triggered by the
|
|
* `issue_labels:changed` WS event after attach/detach so list/board chips
|
|
* and the issue-detail Properties LabelPicker update without a refetch.
|
|
*
|
|
* The byIssue cache backs `LabelPicker`; without patching it, externally
|
|
* driven label changes (agents, other tabs) leave the picker stale until it
|
|
* remounts — `staleTime: Infinity` + `refetchOnWindowFocus: false` (see
|
|
* `query-client.ts`) means focus changes won't recover it.
|
|
*/
|
|
export function onIssueLabelsChanged(
|
|
qc: QueryClient,
|
|
wsId: string,
|
|
issueId: string,
|
|
labels: Label[],
|
|
) {
|
|
for (const [key, data] of qc.getQueriesData<ListIssuesCache>({ queryKey: issueKeys.list(wsId) })) {
|
|
if (data) qc.setQueryData<ListIssuesCache>(key, patchIssueInBuckets(data, issueId, { labels }));
|
|
}
|
|
qc.setQueryData<Issue>(issueKeys.detail(wsId, issueId), (old) =>
|
|
old ? { ...old, labels } : old,
|
|
);
|
|
qc.setQueryData<IssueLabelsResponse>(labelKeys.byIssue(wsId, issueId), (old) =>
|
|
old ? { ...old, labels } : old,
|
|
);
|
|
// Patch the Project Gantt caches in-place: the Gantt view applies
|
|
// `labelFilters` to the row data, so a stale `labels` array would silently
|
|
// hide or surface bars after another tab/agent attached or detached a
|
|
// label. Mutating in place (instead of invalidating) avoids a refetch of
|
|
// the entire scheduled set on every label toggle.
|
|
for (const [key, data] of qc.getQueriesData<Issue[]>({
|
|
queryKey: issueKeys.projectGanttAll(wsId),
|
|
})) {
|
|
if (!data) continue;
|
|
const next = data.map((issue) =>
|
|
issue.id === issueId ? { ...issue, labels } : issue,
|
|
);
|
|
qc.setQueryData<Issue[]>(key, next);
|
|
}
|
|
qc.invalidateQueries({ queryKey: issueKeys.myAll(wsId) });
|
|
qc.invalidateQueries({ queryKey: issueKeys.assigneeGroupsAll(wsId) });
|
|
qc.invalidateQueries({ queryKey: issueKeys.myAssigneeGroupsAll(wsId) });
|
|
}
|
|
|
|
/**
|
|
* Apply a metadata snapshot to the issue detail + list + my-issues caches.
|
|
* The server emits this whenever a single key is set or deleted, so the
|
|
* payload is always the FULL post-mutation map — we replace, not merge.
|
|
*
|
|
* Used for the read-only metadata strip in issue detail. Updates that arrive
|
|
* while no view is mounted still keep the caches accurate so the next render
|
|
* shows the latest state without a refetch.
|
|
*/
|
|
export function onIssueMetadataChanged(
|
|
qc: QueryClient,
|
|
wsId: string,
|
|
issueId: string,
|
|
metadata: IssueMetadata,
|
|
) {
|
|
for (const [key, data] of qc.getQueriesData<ListIssuesCache>({ queryKey: issueKeys.list(wsId) })) {
|
|
if (data) qc.setQueryData<ListIssuesCache>(key, patchIssueInBuckets(data, issueId, { metadata }));
|
|
}
|
|
qc.setQueryData<Issue>(issueKeys.detail(wsId, issueId), (old) =>
|
|
old ? { ...old, metadata } : old,
|
|
);
|
|
qc.invalidateQueries({ queryKey: issueKeys.myAll(wsId) });
|
|
}
|
|
|
|
export function onIssueDeleted(
|
|
qc: QueryClient,
|
|
wsId: string,
|
|
issueId: string,
|
|
) {
|
|
cleanupDeletedIssueCaches(qc, wsId, issueId);
|
|
qc.invalidateQueries({ queryKey: issueKeys.assigneeGroupsAll(wsId) });
|
|
qc.invalidateQueries({ queryKey: issueKeys.myAssigneeGroupsAll(wsId) });
|
|
qc.invalidateQueries({ queryKey: projectKeys.all(wsId) });
|
|
}
|