BUGREPORT-duplicate-draft-on-type.md8.0 KBView on GitHub # Bug report — typing in a thread spawns a second draft ("two drafts in my editor")
## Symptom & impact
Open a thread, start typing a reply, and a **second draft row** appears in the editor (the
`DraftTabs` strip shows up because the thread now has two `isDraft` messages). The user is now
staring at two composers for the same reply. Reported repeatedly; "fixed" several times
(generation guards → identity merge → `userEdited` merge) without the duplication going away.
Reference thread: `19d42810a7c5587f`.
## Root cause (one paragraph)
A local draft and its server twin are reconciled in `mergeDraftsByUserEdited`
([threadSlice.ts:787-851](apps/mail/modules/threads/threadList/store/threadSlice.ts#L787-L851))
**only by `draftSessionId` or `draftId`**. But for a freshly-created reply the two identifiers
are never both present on both rows during the critical window: the **local** row owns a
client-generated `draftSessionId` (a UUID the server never echoes) and its `draftId` is `null`
until the first `drafts.create` response writes it back
([email-composer.tsx:962-975](apps/mail/modules/drafting/components/email-composer.tsx#L962-L975));
the **incoming `mail.get`** row owns the Gmail `draftId` but has **no `draftSessionId`** (the
server strips it). If any `mail.get` payload that already contains the newly-created Gmail draft
flows through `setThreadData` **before** the save write-back stamps `draftId` onto the local row,
`findExistingMatch` finds nothing — `incoming.draftSessionId` is undefined and the local row's
`draftId` is still `null` — so the server draft is appended as a **new** row while the local
`userEdited` row is also kept. Two rows, two different identity keys (`UUID` vs `gd_X`), neither
carrying the other's identity, so they never re-merge. The design doc already flagged this exact
window as "the failure mode that keeps happening"
([DESIGN-userEdited-drafts.md](apps/mail/modules/drafting/DESIGN-userEdited-drafts.md) §2.2 step 10).
## Step-by-step code walkthrough
**1. Reply draft is created locally with a UUID session id and no `draftId`.**
[threadSlice.ts:2287-2311](apps/mail/modules/threads/threadList/store/threadSlice.ts#L2287-L2311) (`createReplyDraft`)
```json
{ "id": "temp-draft-1718000000000", "draftSessionId": "A-uuid",
"draftId": null, "userEdited": true, "isDraft": true }
```
**2. User types → first autosave fires after 1s** ([email-composer.tsx:1649-1661](apps/mail/modules/drafting/components/email-composer.tsx#L1649-L1661)).
`getCurrentDraftInfo` returns `draftId: undefined` (never saved yet)
([email-composer.tsx:749-784](apps/mail/modules/drafting/components/email-composer.tsx#L749-L784)).
The request goes out as `drafts.create` with `{ draftId: undefined, draftSessionId: "A-uuid" }`
([email-composer.tsx:872-884](apps/mail/modules/drafting/components/email-composer.tsx#L872-L884)).
**3. Server creates a brand-new Gmail draft** (no `draftId` → STEP 3 of the driver,
[google.ts:3065-3094](apps/server/src/lib/driver/google.ts#L3065-L3094)) and `syncThreadViaProvider`
mirrors the thread ([send.ts:198-217](apps/server/src/services/mail/send/send.ts#L198-L217)). The
draft now exists server-side as `{ id: "gd_X", draftSessionId: <none> }`.
**4. A `mail.get` result reaches `setThreadData` before the write-back.**
`ThreadDataSync` pipes every `mail.get` result into `setThreadData`
([thread-data-sync.tsx:43-57](apps/mail/modules/threads/thread/components/thread-data-sync.tsx#L43-L57)).
The incoming draft row:
```json
{ "id": "gm_msgX", "draftId": "gd_X", "draftSessionId": null, "userEdited": false, "isDraft": true }
```
**5. The merge cannot connect the two rows → both survive.**
[threadSlice.ts:794-848](apps/mail/modules/threads/threadList/store/threadSlice.ts#L794-L848):
- `findExistingMatch(incoming)`: `incoming.draftSessionId` is null → skip session match; `incoming.draftId = "gd_X"` → look for an existing row with `draftId === "gd_X"`. The local row's `draftId` is still `null` → **no match**.
- The incoming server draft (not `userEdited`, no match) is pushed. `key=[redacted]`.
- The unmatched local `userEdited` row is then appended by the tail loop ([threadSlice.ts:842-848](apps/mail/modules/threads/threadList/store/threadSlice.ts#L842-L848)). `key=[redacted]`.
```json
{ "merged": [
{ "draftId": "gd_X", "draftSessionId": null, "userEdited": false }, // server twin
{ "draftId": null, "draftSessionId": "A-uuid", "userEdited": true } // local original
] }
```
`drafts.length === 2` → `ThreadDraftSection` renders `DraftTabs`
([thread-draft-section.tsx:55-61,170-182](apps/mail/modules/threads/thread/components/thread-draft-section.tsx#L55-L61)). **Two drafts in the editor.**
**6. They never reconcile.** The write-back ([email-composer.tsx:962-975](apps/mail/modules/drafting/components/email-composer.tsx#L962-L975))
stamps `draftId = "gd_X"` onto the row matched by `draftSessionId === "A-uuid"`, so now *both*
rows carry `draftId "gd_X"` — but they have different identity keys (`A-uuid` vs `gd_X`) and the
local one is `userEdited` (protected), so subsequent `mail.get` merges keep both forever.
### Contributing defect (separate, also worth fixing)
`markDraftAsUserEdited` matches the row **only** by `m.draftSessionId === draftSessionId`
([threadSlice.ts:2149-2153](apps/mail/modules/threads/threadList/store/threadSlice.ts#L2149-L2153)),
but a Gmail-loaded draft has `draftSessionId === undefined` and the composer passes its `draftId`
(or `id`) as the `draftSessionId` prop. So for **server-loaded** drafts the `userEdited` flag is
**never set**, leaving the user's in-progress edits unprotected from an incoming `mail.get` body
overwrite. `getCurrentDraftInfo` already matches by all three ids
([email-composer.tsx:757-766](apps/mail/modules/drafting/components/email-composer.tsx#L757-L766));
`markDraftAsUserEdited` does not. This does not by itself cause the duplicate, but it's the same
class of identity-mismatch bug and should be fixed in the same pass.
## Evidence / queries run
- **Code trace** (above) is the primary evidence: the duplication is a deterministic outcome of
the merge once a `mail.get` lands in the `draftId`-null window.
- **Existing tests confirm the gap.** `mergeDraftsByUserEdited.test.ts` covers: local-wins when
**both rows already share `draftId`** (Case A), server-wins (Case B), unsaved-local-survives when
there is **no server counterpart** (Case C), and dedupe of incoming rows sharing identity. There
is **no** test for "local `userEdited` draft with `draftId: undefined` meets its server twin that
carries a `draftId` and no `draftSessionId`" — which is exactly this bug.
- **Axiom (`cedar-prod`, 30d):** `search "19d42810a7c5587f"` → **0 rows**; no
`mail.draft.createDraft` span rows. The thread is not in prod telemetry (user is likely on
staging/local, or it's newer than ingestion). This is expected: the duplication is a
**client-side store race** — the server sees a single, valid `drafts.create`, so server logs
cannot distinguish "one Gmail draft shown twice" from a normal save. (A genuine *two-Gmail-draft*
escalation is possible when the per-process `draftIdBySessionKey` cache misses across pods/restart
— [send.ts:192-200](apps/server/src/services/mail/send/send.ts#L192-L200) — but that is a separate,
rarer failure and not required to explain the reported symptom.)
## Blast radius
- Every new reply/forward draft is exposed to the race; likelihood scales with how often a
`mail.get` for the open thread resolves shortly after the first autosave (thread re-open within
staleTime, `checkSync` divergence reconcile — recently widened to include drafts in
`cd84bf93c` — or any list operation that invalidates `mail.get`).
- The `markDraftAsUserEdited` defect affects every **server-loaded** draft the user edits in place
(Cedar-generated drafts, drafts from a prior session): their edits are unprotected against an
incoming `mail.get` body.
- No data corruption in Gmail in the common (display-duplication) case; the user-facing harm is
confusion + the risk of sending/saving the wrong one of the two composers.