From 6fde8e9dce0bc43c18ed5bcb35e5c4a611dba290 Mon Sep 17 00:00:00 2001 From: Ruben Fiszel Date: Tue, 15 Sep 2026 14:16:11 +0200 Subject: [PATCH] fix: re-issue a draft delete the server never received instead of assuming it landed Co-Authored-By: Claude Opus 5 (1M context) --- frontend/src/lib/itemStore.svelte.ts | 7 +++++-- frontend/src/lib/itemStore.test.ts | 27 +++++++++++++++++++++++++++ 2 files changed, 32 insertions(+), 2 deletions(-) diff --git a/frontend/src/lib/itemStore.svelte.ts b/frontend/src/lib/itemStore.svelte.ts index edfcf92ac9..ac82dd5904 100644 --- a/frontend/src/lib/itemStore.svelte.ts +++ b/frontend/src/lib/itemStore.svelte.ts @@ -407,8 +407,11 @@ class Entry { // copy of that edit, so the item opens on it. One the server *refused* is the other // way round — the response is what won — and `adoptRow` drops it below. const unsent = this.ports.conflicted(key) ? undefined : this.ports.pending(key) - const row = unsent === undefined ? res.draft : unsent - const draft = (row ?? undefined) as V | undefined + // A parked delete is what this tab wants gone, not what is there: the row is still the + // server's, so the reconcile at the end re-issues the delete instead of believing it + // landed and leaving the draft behind with nothing to remove it. + const row = unsent == null ? res.draft : unsent + const draft = (unsent === null ? undefined : row) as V | undefined // The deployed side above always advances: the item did change, and a discard after // the user keeps theirs has to land on what the server holds now. if (!standOff) this.adoptRow(key, row, res.draftSavedAt, rowsAtRead) diff --git a/frontend/src/lib/itemStore.test.ts b/frontend/src/lib/itemStore.test.ts index 9079c2951f..653aa71ec4 100644 --- a/frontend/src/lib/itemStore.test.ts +++ b/frontend/src/lib/itemStore.test.ts @@ -1251,6 +1251,33 @@ describe('item store: conflicts', () => { }) }) + it('re-issues a draft delete the server never received, on reopening', async () => { + const rows = fakeRows() + const store = createItemStore(rows.port) + const key: ItemKey = { workspace: 'w', kind: 'resource', path: 'u/me/r' } + const draft = { ...deployedRes, description: 'a draft to be discarded' } + // The server keeps the draft throughout: the delete never reaches it. + const a = adapter(async () => ({ deployed: deployedRes, draft })) + const first = store.acquire(key, { workspace: 'w', path: 'u/me/r' }, a) + await settle() + rows.failing.add('u/me/r') + expect(await first.handle.discard()).toEqual({ removed: false }) + expect(rows.sent.has('u/me/r')).toBe(false) + first.release() + const writesBefore = rows.writes.length + + const second = store.acquire(key, { workspace: 'w', path: 'u/me/r' }, a) + await settle() + + // Reopening must not read the parked delete as "there is no draft": the row is still the + // server's, so the delete has to go out again rather than be quietly forgotten. + expect(second.handle.value).toEqual(deployedRes) + expect(rows.writes.slice(writesBefore)).toEqual([{ path: 'u/me/r', value: null }]) + rows.failing.delete('u/me/r') + await rows.port.flush(key) + expect(rows.sent.get('u/me/r')).toBeNull() + }) + it('opens a conflicted item afresh, past the payload the server refused', async () => { const rows = fakeRows() const store = createItemStore(rows.port)