Introduced 1 production defect in 180 days, median 23 days to fix.
/**
* Tasks the user has resolved on screen but whose server write has not landed yet.
*
* The two paths have different windows, for different reasons.
*
* DELETING a task is deferred ~5s so Undo is a true undo: the server call destroys the task's
* Gmail draft, and nothing brings that back. Nothing has happened server-side until the window
* closes, so for those 5 seconds the server still reports the task as `todo`.
*
* COMPLETING one is not deferred — the write goes out on the click, because a reload inside a
* 5s window used to take the `setTimeout` down with the JS context and lose the user's tick
* outright. Its window is just the round trip. Undo is a real reopen; only the draft cleanup
* waits. See use-optimistic-task-actions.ts.
*
* Either way, ANY refetch that lands before the write does re-hydrates the row from a server
* that still says `todo`, and the user watches their resolved task pop back in. The window is
* not hard to hit — it is usually self-inflicted. Ticking a second task means the first task's
* own post-write `invalidateQueries` refetches the conversation while the second is still only
* optimistic. Window-focus refetches, the AOP refresh watcher, and enrichment invalidations do
* it too.
*
* Patching the React Query caches at click time does not fix this: a refetch STARTED after the
* click overwrites whatever was patched with server truth. The mask has to outlive the fetch,
* so it lives here and is applied where server data enters the stores the UI actually reads —
* `setConversations` and `hydrateTodoTasks`. Cleared once the server write has landed and the
* follow-up invalidations have settled, at which point server truth agrees on its own.
*
* Module-level rather than store state: it is a transient guard that must survive component
* unmounts and be readable synchronously from inside the slice reducers that consume it.
*/
/** `done` masks the task as completed; `deleted` hides it outright. */
export type PendingTaskResolution = 'done' | 'deleted';
const pendingResolutions = new Map<string, PendingTaskResolution>();
export function markTaskResolutionPending(taskId: string, resolution: PendingTaskResolution): void {
pendingResolutions.set(taskId, resolution);
}
export function clearTaskResolutionPending(taskId: string): void {
pendingResolutions.delete(taskId);
}
export function getPendingTaskResolution(taskId: string): PendingTaskResolution | undefined {
return pendingResolutions.get(taskId);
}
/**
* Overlay the pending resolutions onto a freshly fetched task list.
*
* A pending completion is kept and forced to `done` rather than dropped: the conversation
* checklist filters to `todo` so it stays hidden either way, but the timeline's completion
* animation renders from the `done` row and needs it to survive its ~500ms.
*
* Returns the input array unchanged when nothing is masked, so the callers' change detection
* keeps short-circuiting on identical data.
*/
export function applyPendingTaskResolutions<T extends { id: string; status: string }>(
tasks: readonly T[],
): T[] | readonly T[] {
if (pendingResolutions.size === 0) return tasks;
let changed = false;
const masked: T[] = [];
for (const task of tasks) {
const resolution = pendingResolutions.get(task.id);
if (!resolution) {
masked.push(task);
continue;
}
changed = true;
if (resolution === 'done') {
masked.push({ ...task, status: 'done' });
}
// `deleted` — drop the row entirely.
}
return changed ? masked : tasks;
}