PR #3528
Sections
Review

fix(web): stop a task status from reverting when an older update finishes late

main ← feature/generation-guard-opt-e0d78a 9 files +412 −38 PR #3528 ↗

The PR adds a per-task sequence guard so a late failure from an older status update no longer reverts a newer server-confirmed status.

Why this change

Two fast status updates on the same task can finish out of order. The older request fails after the newer one succeeds, and its unconditional rollback reverts the UI to stale state.

What it does

Architecture, end to end

The guard sits between the UI and the API. Each write gets a sequence. A late failure checks the guard before it restores.

flowchart LR
  User[User drag or picker] --> Board[use-board-drag / StatusPicker]
  Board --> Hook[useOptimisticTaskMutation]
  Hook --> Guard[office-task-content-sync\nsequence + guard]
  Guard --> API[PATCH /office/tasks/:id]
  API --> Backend[Backend persists status]
  Backend --> WS[WS refetch reconciles]
  Guard -- blocks stale restore --> UI[UI keeps newer status]

Key code changes

Drag to pan. Use the + and − buttons to zoom. Click a node to open the full code. The arrows show how the parts interact.

drag to pan · +/− to zoom · click a node for details

shouldRestoreAfterFailedWrite(taskId, scope, sequence): boolean
Click for details →

The guard tracks the last successful sequence and pending writes, and blocks a stale failure from restoring.

Scope and settled record
export type SyncScope = SyncField | "task";
export const TASK_SCOPE: SyncScope = "task";

export function recordWriteSettled(taskId: string, scope: SyncScope, sequence: number): void {
  const fm = fieldMap(lastSuccessfulWriteSequenceByTaskField, taskId);
  const current = fm.get(scope);
  if (current === undefined || sequence > current) {
    fm.set(scope, sequence);
  }
}
Restore gate
export function shouldRestoreAfterFailedWrite(
  taskId: string,
  scope: SyncScope,
  sequence: number,
): boolean {
  const lastSuccess = lastSuccessfulWriteSequenceByTaskField.get(taskId)?.get(scope);
  if (lastSuccess !== undefined && lastSuccess > sequence) return false;
  const pending = guardByTaskField.get(taskId)?.get(scope)?.pendingWrites;
  if (pending) {
    for (const pendingSeq of pending) {
      if (pendingSeq > sequence) return false;
    }
  }
  return true;
}
Write lifecycle
export function beginWrite(taskId: string, scope: SyncScope, sequence: number): void {
  const gm = fieldMap(guardByTaskField, taskId);
  const guard = gm.get(scope) ?? { editorOpen: false, pendingWrites: new Set<number>() };
  guard.pendingWrites.add(sequence);
  gm.set(scope, guard);
  fieldMap(lastWriteIssueSequenceByTaskField, taskId).set(scope, sequence);
}

export function endWrite(taskId: string, scope: SyncScope, sequence: number): void {
  const gm = guardByTaskField.get(taskId);
  const guard = gm?.get(scope);
  if (!gm || !guard) return;
  guard.pendingWrites.delete(sequence);
  releaseIfUnguarded(taskId, scope, gm, guard);
}
Generic optimistic mutation with guardapps/web/hooks/use-optimistic-task-mutation.ts ↗
useOptimisticTaskMutation(): (taskId, patch, apiCall) => Promise<void>
Click for details →

The hook now sequences each mutation and only restores when no newer write has succeeded or is still pending.

Sequenced optimistic patch
const sequence = nextTaskSequence(taskId);
beginWrite(taskId, TASK_SCOPE, sequence);
ctx.applyPatch(patch);
if (storeSnapshot) {
  storeApi.getState().patchTaskInStore(taskId, storePatch);
}
Guarded restore on failure
try {
  await apiCall();
  recordWriteSettled(taskId, TASK_SCOPE, sequence);
  endWrite(taskId, TASK_SCOPE, sequence);
} catch (err) {
  if (err instanceof ApprovalGateError) {
    recordWriteSettled(taskId, TASK_SCOPE, sequence);
  }
  const shouldRestore = shouldRestoreAfterFailedWrite(taskId, TASK_SCOPE, sequence);
  endWrite(taskId, TASK_SCOPE, sequence);
  if (shouldRestore) {
    if (err instanceof ApprovalGateError) {
      const redirectPatch: Partial<Task> = { status: err.redirectedStatus as TaskStatus };
      ctx.applyPatch(redirectPatch);
      if (storeSnapshot) {
        storeApi.getState().patchTaskInStore(taskId, toOfficeTaskPatch(redirectPatch));
      }
    } else {
      ctx.restore(snapshot);
      if (storeSnapshot) {
        const { title: _title, description: _description, ...storeRollback } = storeSnapshot;
        storeApi.getState().patchTaskInStore(taskId, storeRollback);
      }
    }
  }
  toastUpdateFailure(err);
  throw err;
}
applyStatusDrop(taskId, targetStatus, deps): Promise<void>
Click for details →

The board drop uses the same guard so a slow drag that fails does not revert a later successful drop.

Before: unconditional restore
  if (snapshot.status === targetStatus) return;

  const sequence = nextTaskSequence(taskId);
  beginWrite(taskId, TASK_SCOPE, sequence);

  deps.patchTask(taskId, { status: targetStatus });
  try {
    await deps.updateStatus(taskId, targetStatus);
    recordWriteSettled(taskId, TASK_SCOPE, sequence);
    endWrite(taskId, TASK_SCOPE, sequence);
  } catch (err) {
    if (err instanceof ApprovalGateError) {
      deps.patchTask(taskId, { status: err.redirectedStatus });
    } else {
      deps.patchTask(taskId, snapshot);
    if (err instanceof ApprovalGateError) {
      recordWriteSettled(taskId, TASK_SCOPE, sequence);
    }
    const shouldRestore = shouldRestoreAfterFailedWrite(taskId, TASK_SCOPE, sequence);
    endWrite(taskId, TASK_SCOPE, sequence);
    if (shouldRestore) {
      if (err instanceof ApprovalGateError) {
        deps.patchTask(taskId, { status: err.redirectedStatus });
      } else {
        deps.patchTask(taskId, snapshot);
      }
    }
    deps.onError(err instanceof Error ? err.message : t("task:failedToMoveTask"));
  }
Imports
import {
  beginWrite,
  endWrite,
  nextTaskSequence,
  recordWriteSettled,
  shouldRestoreAfterFailedWrite,
  TASK_SCOPE,
} from "@/lib/state/office-task-content-sync";
handleSelect(value: TaskStatus): Promise<void>
Click for details →

The picker now relies on the hook for gate redirects and no longer does a second mutation.

After: delegate to hook
const handleSelect = async (value: TaskStatus) => {
  setOpen(false);
  if (value === current) return;
  try {
    await mutate(task.id, { status: value }, () =>
      updateTaskStatusOrTranslateGate(task.id, value),
    );
  } catch {
    /* toast already raised by hook */
  }
};
Diff: remove double mutate
  const handleSelect = async (value: TaskStatus) => {
    setOpen(false);
    if (value === current) return;
    try {
      await mutate(task.id, { status: value }, () =>
        updateTaskStatusOrTranslateGate(task.id, value),
      );
    } catch (err) {
      if (err instanceof ApprovalGateError) {
        await mutate(task.id, { status: err.redirectedStatus as TaskStatus }, () =>
          Promise.resolve(),
        );
      }
    } catch {
      /* toast already raised by hook */
    }
  };
Read the changes as a list

Per-task sequence guard

apps/web/lib/state/office-task-content-sync.ts

The guard tracks the last successful sequence and pending writes, and blocks a stale failure from restoring.

Scope and settled record
export type SyncScope = SyncField | "task";
export const TASK_SCOPE: SyncScope = "task";

export function recordWriteSettled(taskId: string, scope: SyncScope, sequence: number): void {
  const fm = fieldMap(lastSuccessfulWriteSequenceByTaskField, taskId);
  const current = fm.get(scope);
  if (current === undefined || sequence > current) {
    fm.set(scope, sequence);
  }
}
Restore gate
export function shouldRestoreAfterFailedWrite(
  taskId: string,
  scope: SyncScope,
  sequence: number,
): boolean {
  const lastSuccess = lastSuccessfulWriteSequenceByTaskField.get(taskId)?.get(scope);
  if (lastSuccess !== undefined && lastSuccess > sequence) return false;
  const pending = guardByTaskField.get(taskId)?.get(scope)?.pendingWrites;
  if (pending) {
    for (const pendingSeq of pending) {
      if (pendingSeq > sequence) return false;
    }
  }
  return true;
}
Write lifecycle
export function beginWrite(taskId: string, scope: SyncScope, sequence: number): void {
  const gm = fieldMap(guardByTaskField, taskId);
  const guard = gm.get(scope) ?? { editorOpen: false, pendingWrites: new Set<number>() };
  guard.pendingWrites.add(sequence);
  gm.set(scope, guard);
  fieldMap(lastWriteIssueSequenceByTaskField, taskId).set(scope, sequence);
}

export function endWrite(taskId: string, scope: SyncScope, sequence: number): void {
  const gm = guardByTaskField.get(taskId);
  const guard = gm?.get(scope);
  if (!gm || !guard) return;
  guard.pendingWrites.delete(sequence);
  releaseIfUnguarded(taskId, scope, gm, guard);
}

Generic optimistic mutation with guard

apps/web/hooks/use-optimistic-task-mutation.ts

The hook now sequences each mutation and only restores when no newer write has succeeded or is still pending.

Sequenced optimistic patch
const sequence = nextTaskSequence(taskId);
beginWrite(taskId, TASK_SCOPE, sequence);
ctx.applyPatch(patch);
if (storeSnapshot) {
  storeApi.getState().patchTaskInStore(taskId, storePatch);
}
Guarded restore on failure
try {
  await apiCall();
  recordWriteSettled(taskId, TASK_SCOPE, sequence);
  endWrite(taskId, TASK_SCOPE, sequence);
} catch (err) {
  if (err instanceof ApprovalGateError) {
    recordWriteSettled(taskId, TASK_SCOPE, sequence);
  }
  const shouldRestore = shouldRestoreAfterFailedWrite(taskId, TASK_SCOPE, sequence);
  endWrite(taskId, TASK_SCOPE, sequence);
  if (shouldRestore) {
    if (err instanceof ApprovalGateError) {
      const redirectPatch: Partial<Task> = { status: err.redirectedStatus as TaskStatus };
      ctx.applyPatch(redirectPatch);
      if (storeSnapshot) {
        storeApi.getState().patchTaskInStore(taskId, toOfficeTaskPatch(redirectPatch));
      }
    } else {
      ctx.restore(snapshot);
      if (storeSnapshot) {
        const { title: _title, description: _description, ...storeRollback } = storeSnapshot;
        storeApi.getState().patchTaskInStore(taskId, storeRollback);
      }
    }
  }
  toastUpdateFailure(err);
  throw err;
}

Board drag with sequence guard

apps/web/app/office/tasks/use-board-drag.ts

The board drop uses the same guard so a slow drag that fails does not revert a later successful drop.

Before: unconditional restore
  if (snapshot.status === targetStatus) return;

  const sequence = nextTaskSequence(taskId);
  beginWrite(taskId, TASK_SCOPE, sequence);

  deps.patchTask(taskId, { status: targetStatus });
  try {
    await deps.updateStatus(taskId, targetStatus);
    recordWriteSettled(taskId, TASK_SCOPE, sequence);
    endWrite(taskId, TASK_SCOPE, sequence);
  } catch (err) {
    if (err instanceof ApprovalGateError) {
      deps.patchTask(taskId, { status: err.redirectedStatus });
    } else {
      deps.patchTask(taskId, snapshot);
    if (err instanceof ApprovalGateError) {
      recordWriteSettled(taskId, TASK_SCOPE, sequence);
    }
    const shouldRestore = shouldRestoreAfterFailedWrite(taskId, TASK_SCOPE, sequence);
    endWrite(taskId, TASK_SCOPE, sequence);
    if (shouldRestore) {
      if (err instanceof ApprovalGateError) {
        deps.patchTask(taskId, { status: err.redirectedStatus });
      } else {
        deps.patchTask(taskId, snapshot);
      }
    }
    deps.onError(err instanceof Error ? err.message : t("task:failedToMoveTask"));
  }
Imports
import {
  beginWrite,
  endWrite,
  nextTaskSequence,
  recordWriteSettled,
  shouldRestoreAfterFailedWrite,
  TASK_SCOPE,
} from "@/lib/state/office-task-content-sync";

Status picker simplification

apps/web/components/task/simple/components/status-picker.tsx

The picker now relies on the hook for gate redirects and no longer does a second mutation.

After: delegate to hook
const handleSelect = async (value: TaskStatus) => {
  setOpen(false);
  if (value === current) return;
  try {
    await mutate(task.id, { status: value }, () =>
      updateTaskStatusOrTranslateGate(task.id, value),
    );
  } catch {
    /* toast already raised by hook */
  }
};
Diff: remove double mutate
  const handleSelect = async (value: TaskStatus) => {
    setOpen(false);
    if (value === current) return;
    try {
      await mutate(task.id, { status: value }, () =>
        updateTaskStatusOrTranslateGate(task.id, value),
      );
    } catch (err) {
      if (err instanceof ApprovalGateError) {
        await mutate(task.id, { status: err.redirectedStatus as TaskStatus }, () =>
          Promise.resolve(),
        );
      }
    } catch {
      /* toast already raised by hook */
    }
  };

Data and storage

In-memory guard state per task. No schema migration. The TASK_SCOPE extends the existing title/description guard to whole-task mutations.

FieldTypeNotes
sequenceByTaskMap<string, number>monotonic counter per task, issued at request start
guardByTaskFieldMap<taskId, Map<SyncScope, GuardState>>pendingWrites set per scope, drives shouldRestore check
lastSuccessfulWriteSequenceByTaskFieldMap<taskId, Map<SyncScope, number>>highest settled sequence, blocks older restores
lastWriteIssueSequenceByTaskFieldMap<taskId, Map<SyncScope, number>>latest issued sequence, used for refetch staleness

Risk

3 / 10 Low
1 low5 medium10 high

Why this score

  • Change is UI-only and limited to office task status; no backend or migration.
  • Strong coverage: unit tests for guard, component tests for picker, and E2E race test.
  • Rollback is safe: revert restores prior unconditional restore behavior.

Trade-offs and review notes

Where to look first

  1. Check office-task-content-sync.ts: shouldRestoreAfterFailedWrite correctly checks both lastSuccess and pendingWrites.
  2. Check use-optimistic-task-mutation.ts: recordWriteSettled is called for both success and ApprovalGateError before the guard check.
  3. Check use-board-drag.ts: same guard pattern as the hook, with snapshot vs redirect patch.
  4. Check status-picker.tsx: handleSelect no longer does a second mutate and correctly swallows the error.