From 6cbf34c045894580371aa9a59d4bac388e91d67f Mon Sep 17 00:00:00 2001 From: Ruben Fiszel Date: Tue, 15 Sep 2026 10:14:03 +0200 Subject: [PATCH] fix: discard the value as of the click, not as of its turn Co-Authored-By: Claude Opus 5 (1M context) --- frontend/src/lib/itemStore.svelte.ts | 5 +++++ frontend/src/lib/itemStore.test.ts | 26 ++++++++++++++++++++++++++ 2 files changed, 31 insertions(+) diff --git a/frontend/src/lib/itemStore.svelte.ts b/frontend/src/lib/itemStore.svelte.ts index a37244e081..6a1f06de7f 100644 --- a/frontend/src/lib/itemStore.svelte.ts +++ b/frontend/src/lib/itemStore.svelte.ts @@ -472,8 +472,13 @@ class Entry { } discard(): Promise { + // What a discard throws away is the value as of the click, like a save sends the value as + // of the click: one typed while it waited its turn behind a save is newer than it, and + // reverting that would drop it with nothing left to recover it from. + const askedAt = this.edits return this.run(async () => { if (!this.loaded || this.retired) return { removed: false } + if (this.edits !== askedAt) return { removed: false } if (this.origin === 'draft') { const kept = snapshot(this.value) const keptRow = this.row diff --git a/frontend/src/lib/itemStore.test.ts b/frontend/src/lib/itemStore.test.ts index 447347b918..2a8a310a45 100644 --- a/frontend/src/lib/itemStore.test.ts +++ b/frontend/src/lib/itemStore.test.ts @@ -493,6 +493,32 @@ describe('item store: origins', () => { expect(rows.writes).toEqual([{ path: 'u/me/r', value: null }]) }) + it('keeps an edit typed after a discard was asked for, while it waited its turn', async () => { + const rows = fakeRows() + const gate = deferred() + const { item } = await open( + rows, + adapter({ deployed: deployedRes }, () => gate.promise) + ) + item.value = { ...deployedRes, description: 'edited' } + const saving = item.save() + await settle() + const discarding = item.discard() + await settle() + item.value = { ...deployedRes, description: 'typed after the discard' } + gate.resolve() + + expect(await saving).toMatchObject({ ok: true }) + // The discard is older than what is on screen: reverting to `deployed` would drop it. + expect(await discarding).toEqual({ removed: false }) + expect(item.value).toEqual({ ...deployedRes, description: 'typed after the discard' }) + expect(item.dirty).toBe(true) + expect(rows.writes.at(-1)).toEqual({ + path: 'u/me/r', + value: { ...deployedRes, description: 'typed after the discard' } + }) + }) + it('keeps a draft-only item an outside write put back while its delete was going', async () => { const rows = fakeRows() const draft = { ...deployedRes, description: 'only a draft' }