Compare commits

...

1 Commits

Author SHA1 Message Date
Naiyuan Qing
28edfb5026 fix(issues): emit project_changed so moved issues leave the old project list (MUL-3669)
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>
2026-06-25 16:38:52 +08:00
7 changed files with 124 additions and 11 deletions

View File

@@ -431,6 +431,26 @@ describe("useUpdateIssue — optimistic move keeps every bucketed board in sync"
expect(invalidatedKeys).not.toContainEqual(issueKeys.list(WS_ID));
expect(invalidatedKeys).not.toContainEqual(issueKeys.myAll(WS_ID));
});
it("invalidates myAll on settle when project_id changes (drops the issue from the old project's list)", async () => {
// A project move makes the issue leave the old project's filtered list. The
// surgical patch is filter-blind (it never removes a card that no longer
// matches the list filter), so onSettled must refetch myAll to drop it —
// unlike a status-only move, which deliberately does not (MUL-3669 / #4548).
updateIssue.mockResolvedValue(makeIssue(1, { project_id: "project-9" }));
const invalidateSpy = vi.spyOn(qc, "invalidateQueries");
const { result } = renderHook(() => useUpdateIssue(), {
wrapper: createWrapper(qc),
});
await act(async () => {
await result.current.mutateAsync({ id: "issue-1", project_id: "project-9" });
});
const invalidatedKeys = invalidateSpy.mock.calls.map((c) => c[0]?.queryKey);
expect(invalidatedKeys).toContainEqual(issueKeys.myAll(WS_ID));
});
});
describe("useBatchUpdateIssues — optimistic patch covers filtered boards too", () => {
@@ -541,6 +561,28 @@ describe("useBatchUpdateIssues — optimistic patch covers filtered boards too",
const invalidatedKeys = invalidateSpy.mock.calls.map((c) => c[0]?.queryKey);
expect(invalidatedKeys).not.toContainEqual(issueKeys.list(WS_ID));
});
it("invalidates myAll on settle when project_id changes (drops moved issues from the old project's list)", async () => {
// Mirrors useUpdateIssue: a batch that moves issues between projects must
// refetch myAll so they leave the old project's filtered list, even though a
// status-only batch deliberately does not (MUL-3669 / #4548).
batchUpdateIssues.mockResolvedValue({ updated: 1 });
const invalidateSpy = vi.spyOn(qc, "invalidateQueries");
const { result } = renderHook(() => useBatchUpdateIssues(), {
wrapper: createWrapper(qc),
});
await act(async () => {
await result.current.mutateAsync({
ids: ["issue-1"],
updates: { project_id: "project-9" },
});
});
const invalidatedKeys = invalidateSpy.mock.calls.map((c) => c[0]?.queryKey);
expect(invalidatedKeys).toContainEqual(issueKeys.myAll(WS_ID));
});
});
describe("useResolveComment", () => {

View File

@@ -333,6 +333,15 @@ export function useUpdateIssue() {
) {
qc.invalidateQueries({ queryKey: projectKeys.all(wsId) });
}
// Local safety net for a project move. The WS echo now carries
// project_changed, but a moved issue must also drop out of the OLD
// project's filtered list here in case the echo is delayed or dropped. The
// surgical onMutate patch is filter-blind — it never removes a card that no
// longer matches the list's project filter — so reconcile by refetching
// myAll whenever project_id was part of this update (MUL-3669 / #4548).
if (Object.prototype.hasOwnProperty.call(vars, "project_id")) {
qc.invalidateQueries({ queryKey: issueKeys.myAll(wsId) });
}
// Refresh the issue's attachments cache when the description editor
// bound new uploads — the description editor reads `issueAttachments`
// to resolve text-preview Eye gates, and unlike other mutations this
@@ -523,6 +532,11 @@ export function useBatchUpdateIssues() {
) {
qc.invalidateQueries({ queryKey: projectKeys.all(wsId) });
}
// Local safety net mirroring useUpdateIssue: drop moved issues from the old
// project's filtered list even if the WS echo is delayed (MUL-3669 / #4548).
if (Object.prototype.hasOwnProperty.call(_vars.updates, "project_id")) {
qc.invalidateQueries({ queryKey: issueKeys.myAll(wsId) });
}
if (ctx?.affectedParentIds && ctx.affectedParentIds.size > 0) {
for (const parentId of ctx.affectedParentIds) {
qc.invalidateQueries({

View File

@@ -317,11 +317,43 @@ describe("onIssueUpdated — position move is surgical, not a list refetch", ()
it("invalidates myAll when the project changes (Project board membership)", () => {
qc.setQueryData<ListIssuesCache>(issueKeys.myAll(WS_ID), makeListCache(issueA));
// issueA.project_id is null; moving it into a project shifts Project-board membership.
// issueA.project_id is null; moving it into a project shifts Project-board
// membership. No server flag here — this exercises the legacy cache-diff
// fallback that keeps a new frontend working against an older backend.
onIssueUpdated(qc, WS_ID, { ...issueA, project_id: "project-9" });
expectInvalidated(qc, issueKeys.myAll(WS_ID));
});
it("invalidates myAll on a server project_changed flag even when the cached project_id already matches (local optimistic move)", () => {
// Reproduces the post-optimistic-move state behind MUL-3669: onMutate has
// already written the NEW project into detail + list, so a cache diff would
// compute projectChanged=false and skip the refetch. The authoritative
// server flag must still drive it.
const moved: Issue = { ...issueA, project_id: "project-9" };
qc.setQueryData<Issue>(issueKeys.detail(WS_ID, moved.id), moved);
qc.setQueryData<ListIssuesCache>(issueKeys.myAll(WS_ID), makeListCache(moved));
onIssueUpdated(qc, WS_ID, moved, { projectChanged: true });
expectInvalidated(qc, issueKeys.myAll(WS_ID));
});
it("does NOT invalidate myAll when the server flag says project_changed=false (flag overrides the legacy diff)", () => {
// No detail/list cache for the issue, so the legacy diff would resolve
// oldProjectId=null and fire on the non-null incoming project_id. An explicit
// false flag from the server is authoritative and must suppress that.
qc.setQueryData<ListIssuesCache>(issueKeys.myAll(WS_ID), makeListCache(issueA));
onIssueUpdated(
qc,
WS_ID,
{ ...issueA, project_id: "project-9" },
{ projectChanged: false },
);
expect(qc.getQueryState(issueKeys.myAll(WS_ID))?.isInvalidated).toBe(false);
});
});
// A board column header shows `byStatus[status].total`. On a status change the

View File

@@ -40,11 +40,16 @@ export function onIssueUpdated(
qc: QueryClient,
wsId: string,
issue: Partial<Issue> & { id: string },
// assigneeChanged / statusChanged come from the server's issue:updated flags.
// assigneeChanged gates 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 } = {},
// 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
@@ -61,14 +66,21 @@ export function onIssueUpdated(
const parentChanged =
issue.parent_issue_id !== undefined && newParentId !== oldParentId;
// Project-board membership keys on project_id. There is no project_changed
// flag on the wire, so diff the incoming project_id against the cached one.
// 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 =
issue.project_id !== undefined && (issue.project_id ?? null) !== oldProjectId;
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

View File

@@ -589,6 +589,7 @@ export function useRealtimeSync(
onIssueUpdated(qc, wsId, issue, {
assigneeChanged: payload.assignee_changed,
statusChanged: payload.status_changed,
projectChanged: payload.project_changed,
});
if (issue.status) {
onInboxIssueStatusChanged(qc, wsId, issue.id, issue.status);

View File

@@ -98,10 +98,14 @@ export interface IssueUpdatedPayload {
// (server/internal/handler/issue.go publish). assignee_changed lets the
// realtime layer keep filtered myList caches in place on a non-membership
// change instead of refetching; status_changed lets it reconcile board column
// counts when a status change lands on an off-screen (unloaded) issue. Other
// change flags are present on the wire too and can be surfaced here when needed.
// counts when a status change lands on an off-screen (unloaded) issue;
// project_changed lets it drop a moved issue from the old project's filtered
// list (the client-side cache diff is unreliable after an optimistic local
// move — MUL-3669 / #4548). Other change flags are present on the wire too and
// can be surfaced here when needed.
assignee_changed?: boolean;
status_changed?: boolean;
project_changed?: boolean;
}
export interface IssueDeletedPayload {

View File

@@ -2531,6 +2531,11 @@ func (h *Handler) UpdateIssue(w http.ResponseWriter, r *http.Request) {
(prevIssue.AssigneeType.String != issue.AssigneeType.String || uuidToString(prevIssue.AssigneeID) != uuidToString(issue.AssigneeID))
statusChanged := req.Status != nil && prevIssue.Status != issue.Status
priorityChanged := req.Priority != nil && prevIssue.Priority != issue.Priority
// project_changed gates the client's per-project issue-list refetch the way
// status/assignee flags gate theirs. Without it the client must diff
// project_id against its own cache, which breaks once an optimistic local
// move has overwritten the cached value (MUL-3669 / #4548).
projectChanged := req.ProjectID != nil && uuidToString(prevIssue.ProjectID) != uuidToString(issue.ProjectID)
descriptionChanged := req.Description != nil && textToPtr(prevIssue.Description) != resp.Description
titleChanged := req.Title != nil && prevIssue.Title != issue.Title
prevStartDate := dateToPtr(prevIssue.StartDate)
@@ -2548,6 +2553,7 @@ func (h *Handler) UpdateIssue(w http.ResponseWriter, r *http.Request) {
"assignee_changed": assigneeChanged,
"status_changed": statusChanged,
"priority_changed": priorityChanged,
"project_changed": projectChanged,
"start_date_changed": startDateChanged,
"due_date_changed": dueDateChanged,
"description_changed": descriptionChanged,
@@ -3053,12 +3059,14 @@ func (h *Handler) BatchUpdateIssues(w http.ResponseWriter, r *http.Request) {
(prevIssue.AssigneeType.String != issue.AssigneeType.String || uuidToString(prevIssue.AssigneeID) != uuidToString(issue.AssigneeID))
statusChanged := req.Updates.Status != nil && prevIssue.Status != issue.Status
priorityChanged := req.Updates.Priority != nil && prevIssue.Priority != issue.Priority
projectChanged := req.Updates.ProjectID != nil && uuidToString(prevIssue.ProjectID) != uuidToString(issue.ProjectID)
h.publish(protocol.EventIssueUpdated, workspaceID, actorType, actorID, map[string]any{
"issue": resp,
"assignee_changed": assigneeChanged,
"status_changed": statusChanged,
"priority_changed": priorityChanged,
"project_changed": projectChanged,
})
if assigneeChanged {