pending-task-resolutions.ts3.5 KBView on GitHub

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;
}