diff --git a/frontend/src/lib/itemStore.svelte.ts b/frontend/src/lib/itemStore.svelte.ts index d5ba1290ca..30a3478fb3 100644 --- a/frontend/src/lib/itemStore.svelte.ts +++ b/frontend/src/lib/itemStore.svelte.ts @@ -412,6 +412,7 @@ class Entry { let rowsAtRead = 0 let valuesAtRead = 0 let unbasedPending = false + let refusedBefore = false let waitingBefore: unknown | undefined let res: ItemLoad try { @@ -423,6 +424,8 @@ class Entry { // would go out unconditional and take over a row created since. The read below // gives it one, and `pending` keeps showing it meanwhile. unbasedPending = this.ports.unbased(key) + // Whether the server had already refused this key before this read went near it. + refusedBefore = this.ports.conflicted(key) if (!unbasedPending) await this.ports.flush(key) // Taken here, not when the command was asked for: rows this read's own flush sent // are in the response it is about to get. Only one handed over from now on, while @@ -472,7 +475,10 @@ class Entry { // A row the syncer took but could not send is newer than this response and is the only // 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 : waitingBefore + // A payload the server refused *before* this read is stale: the response is what won. + // One refused by this read's own retry is not — it is the user's unsent edit, and the + // only copy of it, so it stays on screen with the conflict for them to settle. + const unsent = refusedBefore ? undefined : waitingBefore // 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. diff --git a/frontend/src/lib/itemStore.test.ts b/frontend/src/lib/itemStore.test.ts index 0899fa69a8..ec6e60fe5e 100644 --- a/frontend/src/lib/itemStore.test.ts +++ b/frontend/src/lib/itemStore.test.ts @@ -2051,6 +2051,40 @@ describe('item store: conflicts', () => { expect(rows.sent.get('u/me/r')).toBeNull() }) + it('keeps the failed edit its own reopening retry got refused', async () => { + const rows = fakeRows() + const store = createItemStore(rows.port) + const key: ItemKey = { workspace: 'w', kind: 'resource', path: 'u/me/r' } + const a = adapter(async () => ({ + deployed: deployedRes, + draft: rows.sent.get('u/me/r') as Res | undefined, + draftSavedAt: 'T1' + })) + // A draft exists, so this key has a baseline and its autosave is not unconditional. + rows.sent.set('u/me/r', { ...deployedRes, description: 'the first draft' }) + const first = store.acquire(key, { workspace: 'w', path: 'u/me/r' }, a) + await settle() + rows.failing.add('u/me/r') + const mine = { ...deployedRes, description: 'mine, never sent' } + first.handle.value = mine + await rows.port.flush(key) + first.release() + + // Another tab writes a newer draft, and this tab's network recovers. Reopening retries + // the parked edit before reading, and that retry is what the server refuses. + rows.sent.set('u/me/r', { ...deployedRes, description: 'the other tab' }) + rows.failing.delete('u/me/r') + rows.conflictOnFlush.add('u/me/r') + + const second = store.acquire(key, { workspace: 'w', path: 'u/me/r' }, a) + await settle() + + // That edit is the only copy anyone has. Dropping it for the remote draft and clearing + // the conflict on the way past would lose it with nothing said. + expect(second.handle.value).toEqual(mine) + expect(second.handle.status).toBe('conflicted') + }) + it('opens a conflicted item afresh, past the payload the server refused', async () => { const rows = fakeRows() const store = createItemStore(rows.port)