The dirty flags were already defined — the sync useEffect just wasn't looking at them
A bug report: editing a task on Desktop, click something else like attachments, and what you'd been typing vanishes. The cause was a sync useEffect that reset every local draft on every currentTask change. The fix was reading the dirty flags that already existed in the same file.
Edits that vanished mid-typing
A user report kicked it off.
“On Desktop, while you’re editing a task — if you click somewhere else, like attachments — what you’d been typing disappears. Heard a few reports.”
The repro was simple. In the task detail screen, type something into the title field. While still typing, click the Add attachment button. The title input snaps back to the old server value, mid-word. From the user’s side: the characters they just typed are gone.
Cause — a too-eager sync useEffect
Both TaskRecordDetail.tsx and TaskTemplateDetail.tsx had the same pattern:
useEffect(() => {
setTitle(currentTask.title ?? "");
setAddress(currentTask.address ?? "");
setDescriptionDraft(null);
setTargetDraft(null);
setExpectationDraft(null);
setObstacleDraft(null);
setRetrospectDraft(null);
// ... toggles, cleanup ...
}, [currentTask.id, task.revision]);
Whenever task.revision changed — meaning the task got updated from anywhere — every local draft was unconditionally reset to the server value. The intent was likely “follow external updates” (changes from another device, SSE, etc.). But the same path swallowed the user’s own concurrent edits exactly the same way.
When you click Add attachment:
- User is typing into title (local
title≠ server) - Attachment upload call goes out
- Server response →
handleTaskUpdate(updated)→ atom updates - New
currentTaskreference →task.revisionchanges - The sync useEffect fires →
setTitle(currentTask.title ?? "")→ the user’s draft is wiped to the old value
The answer was already in the same file
The interesting part: right above this useEffect, the dirty flags already existed.
// Dirty flags
const titleDirty = title !== (currentTask.title ?? "");
const descriptionDirty = descriptionDraft !== null && descriptionDraft !== (currentTask.description ?? "");
const retrospectDirty = retrospectDraft !== null && retrospectDraft !== (currentTask.retrospect ?? "");
const targetDirty = targetDraft !== null && targetDraft !== (currentTask.target ?? "");
// ... etc
Their original purpose was to control the visibility of InlineEditButtons (the confirm/cancel buttons that appear only while a field is being edited). A flag for “is the user currently editing this?” was already in place. The sync useEffect just wasn’t reading it.
The fix was honest. Skip the reset on dirty fields, take server values for the rest.
useEffect(() => {
if (!titleDirty) setTitle(currentTask.title ?? "");
if (!addressDirty) setAddress(currentTask.address ?? "");
if (!descriptionDirty) {
setDescriptionDraft(null);
setDescriptionResetKey((k) => k + 1);
}
if (!retrospectDirty) {
setRetrospectDraft(null);
setRetrospectResetKey((k) => k + 1);
}
// ... same for other drafts
// Toggles still always take server values:
setShowDetails(currentTask.showDetails ?? false);
setShowAttachments(currentTask.showAttachments ?? false);
// ...
}, [currentTask.id, task.revision]);
Now external updates flow in, the fields the user is editing stay intact, and the fields they aren’t touching follow the server.
Why a latent bug suddenly became visible
This bug probably lived in the code for a long time. It only surfaced this week, and the reason was an adjacent piece of work.
I’d just finished sprinkling explicit taskTemplateMutations.upsertModel(response) calls after every template set* API call. Before that, cache updates trickled in through SSE a few hundred milliseconds late. Now the atom updates the moment the server responds. The cadence and immediacy of currentTask changes both went up — and so did how often the sync useEffect fired.
Spots that luck had been masking became visible the moment the system started running fast and consistent.
Previously, a user could click the attachment button, keep typing in the few hundred ms before SSE caught up, and the local title they’d been typing would survive. Lucky timing. After today’s work, that window was gone. So what had always been broken finally looked broken.
Closing thoughts
The key fact about this bug is that the answer was already in the same file. A solution sat one screen above the broken code — and the useEffect just wasn’t looking.
Two reasons I can guess at, for why it wasn’t looking:
- The useEffect was probably written before dirty flags existed. Dirty flags got introduced later — for
InlineEditButtons— and at that point nobody walked back to the sync useEffect and asked “should this also check dirty?” - The useEffect was framed in someone’s head as “follow external updates,” not “preserve user input.” The first framing makes the second one invisible. You handle what you’ve named.
The principle that a single useEffect should hold one concern is right. But the concern has to be named broadly enough. “Follow external updates” is one concern; “preserve user input that’s still in flight” is the same concern’s other half. If you only name one side, the other side gets dropped.
What I liked about this fix: it was small. No new dirty flags. No new architecture. Just look at the answer that’s already there.