Compare commits

...

2 Commits

Author SHA1 Message Date
Jiang Bohan
b4b4159b66 fix(labels): only patch attach when prev label set is known
GPT-Boy's review caught a corruption case: when byIssue cache was
unpopulated (user clicked before issueLabelsOptions resolved), the
optimistic patch fell back to an empty prev.labels, then mirrored
[label] into issue list/detail via onIssueLabelsChanged — wiping any
denormalized labels already on the issue. Worse, onError only restored
byIssue when ctx.prev existed, so the wipe persisted on failure.

Match useDetachLabel's invariant: skip the optimistic patch unless prev
is in cache. The chip will wait for the round-trip in the rare race
window, but caches stay consistent and rollback always works.
2026-04-27 18:04:21 +08:00
Jiang Bohan
1998e323a1 fix(labels): apply attach optimistically so chips render before round-trip
Attach went through onSuccess only, so users waited for the server
before seeing the new chip — out of step with detach (already optimistic)
and with status/assignee/priority via useUpdateIssue. Mirror the detach
pattern: snapshot the byIssue cache, look up the full label from the
workspace list cache, patch byIssue + the issue list/detail caches via
onIssueLabelsChanged in onMutate, and roll back on error. onSuccess and
onSettled keep the existing reconcile behavior.
2026-04-27 17:40:14 +08:00

View File

@@ -101,15 +101,37 @@ export function useAttachLabel(issueId: string) {
const wsId = useWorkspaceId();
return useMutation({
mutationFn: (labelId: string) => api.attachLabel(issueId, labelId),
onMutate: async (labelId) => {
await qc.cancelQueries({ queryKey: labelKeys.byIssue(wsId, issueId) });
const prev = qc.getQueryData<IssueLabelsResponse>(labelKeys.byIssue(wsId, issueId));
// Only patch when we already know the current label set — otherwise
// appending `[label]` to an empty array would wipe denormalized
// labels in issue list/detail caches and rollback couldn't restore
// them. If byIssue isn't cached yet (user clicked before the picker
// fetched), skip the optimistic patch and rely on onSettled refetch.
if (!prev) return { prev };
if (prev.labels.some((l) => l.id === labelId)) return { prev };
const list = qc.getQueryData<ListLabelsResponse>(labelKeys.list(wsId));
const label = list?.labels.find((l) => l.id === labelId);
if (!label) return { prev };
const next: IssueLabelsResponse = { ...prev, labels: [...prev.labels, label] };
qc.setQueryData<IssueLabelsResponse>(labelKeys.byIssue(wsId, issueId), next);
onIssueLabelsChanged(qc, wsId, issueId, next.labels);
return { prev };
},
onError: (_err, _id, ctx) => {
if (ctx?.prev) {
qc.setQueryData(labelKeys.byIssue(wsId, issueId), ctx.prev);
onIssueLabelsChanged(qc, wsId, issueId, ctx.prev.labels);
}
},
onSuccess: (data: IssueLabelsResponse) => {
// Backend may return an empty object when the post-mutation read fails
// (it logs a warning and skips the broadcast). We only apply the list
// when the backend gave us one — otherwise rely on onSettled's
// invalidation to refetch.
// (it logs a warning and skips the broadcast). Only apply the list
// when the backend gave us one — otherwise the optimistic patch from
// onMutate stands until onSettled's invalidation refetches.
if (data && Array.isArray(data.labels)) {
qc.setQueryData<IssueLabelsResponse>(labelKeys.byIssue(wsId, issueId), data);
// Mirror into the issues list / detail caches so list/board chips
// update immediately for the actor without waiting for the WS event.
onIssueLabelsChanged(qc, wsId, issueId, data.labels);
}
},