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