From bf61fd698b11fb8f720309ffe795af2af9309f07 Mon Sep 17 00:00:00 2001 From: Brad Groux Date: Sat, 31 Jan 2026 04:08:06 -0600 Subject: [PATCH] fix: preserve timer state during debounced field saves (#7) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit When editing a task field (title, description, etc.) while a timer was running, the debounced save's onSuccess handler would overwrite the timeTracking cache with stale server data. This happened because: 1. User stops timer → useStopTimer patches cache (isRunning: false) 2. Pending debounced save fires → server responds with pre-stop data 3. useUpdateTask.onSuccess writes server response → isRunning: true again The fix: in useUpdateTask.onSuccess, preserve the cached timeTracking data unless the update explicitly included timeTracking changes. Since useDebouncedSave only sends dirty fields (title, description, etc.), timeTracking is never in the update input for normal field edits, so the cache's more recent timer state is preserved. Fixes #7 --- web/src/hooks/useTasks.ts | 28 ++++++++++++++++++++-------- 1 file changed, 20 insertions(+), 8 deletions(-) diff --git a/web/src/hooks/useTasks.ts b/web/src/hooks/useTasks.ts index 06f0ebae..0bcce86f 100644 --- a/web/src/hooks/useTasks.ts +++ b/web/src/hooks/useTasks.ts @@ -92,16 +92,28 @@ export function useUpdateTask() { return useMutation({ mutationFn: ({ id, input }: { id: string; input: UpdateTaskInput }) => api.tasks.update(id, input), - // On success, patch the cache with the server response (authoritative) - // No optimistic update here — useDebouncedSave provides instant UI feedback - // via localTask, and optimistic updates were causing race conditions with - // timer start/stop mutations (cancelQueries would abort their cache patches). - onSuccess: (task) => { - // Patch both the list and individual caches with the real server response + // On success, merge the server response with the current cache. + // Preserve timeTracking from the cache if it wasn't part of this update, + // since concurrent timer mutations (start/stop) may have already patched + // the cache with newer time tracking state. Without this, the debounced + // save response (which doesn't include timeTracking changes) would + // overwrite the timer stop/start that happened in between. + onSuccess: (serverTask, { input }) => { + const mergeWithCachedTimeTracking = (cached: Task | undefined): Task => { + if (!cached || input.timeTracking !== undefined) { + // If this update explicitly included timeTracking, use server response as-is + return serverTask; + } + // Preserve the cached timeTracking (which may reflect a more recent timer mutation) + return { ...serverTask, timeTracking: cached.timeTracking }; + }; + queryClient.setQueryData(['tasks'], (old) => - old ? old.map((t) => (t.id === task.id ? task : t)) : old + old ? old.map((t) => (t.id === serverTask.id ? mergeWithCachedTimeTracking(t) : t)) : old ); - queryClient.setQueryData(['tasks', task.id], task); + + const cachedTask = queryClient.getQueryData(['tasks', serverTask.id]); + queryClient.setQueryData(['tasks', serverTask.id], mergeWithCachedTimeTracking(cachedTask)); }, // Always refetch to sync with server onSettled: () => {