diff --git a/frontend/src/lib/itemStore.svelte.ts b/frontend/src/lib/itemStore.svelte.ts index 3023a1af0e..09d3f40775 100644 --- a/frontend/src/lib/itemStore.svelte.ts +++ b/frontend/src/lib/itemStore.svelte.ts @@ -145,9 +145,9 @@ let nextPatch = 1 const superseded = { ok: false, error: 'Another save of this item replaced this one' } as const -/** An item as it stood when a command was asked for: the value on screen, the count of edits - * and outside writes it has taken, and the count of rows sent for its key. */ -type AsOf = { value: string | undefined; edits: number; rows: number } +/** An item as it stood when a command was asked for: whether it was on screen at all, the value + * it showed, the count of edits and outside writes it has taken, and the rows sent for its key. */ +type AsOf = { loaded: boolean; value: string | undefined; edits: number; rows: number } class Entry { key: ItemKey = $state()! @@ -280,14 +280,10 @@ class Entry { this.ports.write(key, desired === null ? null : snapshot(this.value)) } - /** - * Take the row a read reported, as both the baseline `reconcile` diffs against and the - * syncer's `last_sync`. The two are one fact and are taken together, against `asOf`: the - * count of rows sent when the read was issued. A row sent since is newer than the one the - * read saw, and seeding behind it puts `last_sync` before what the syncer has already sent, - * which the server refuses from then on. Every reader of a row goes through here, so the - * ordering cannot be left out. - */ + /** The row a read reported, as both the baseline `reconcile` diffs against and the syncer's + * `last_sync`: one fact, so taken together and against one `asOf`, the rows sent when the + * read was issued. Seeding behind a row sent since puts `last_sync` before what the syncer + * has already sent, which the server refuses from then on. */ private adoptRow(key: ItemKey, draft: unknown, draftSavedAt: string | undefined, asOf: number) { if (this.rowWrites !== asOf) return this.row = serialize(draft) ?? null @@ -336,7 +332,12 @@ class Entry { /** The registers that move on their own: what the user is editing, and the rows already sent. * A command compares against these to tell what has happened since it was asked for. */ private asOf(): AsOf { - return { value: serialize(this.value), edits: this.edits, rows: this.rowWrites } + return { + loaded: this.loaded, + value: serialize(this.value), + edits: this.edits, + rows: this.rowWrites + } } /** Count a command as started now, ahead of its turn in the queue; returns its release. */ @@ -523,7 +524,9 @@ class Entry { // of what is already on screen, changing nothing the user sees, leaves the discard good. return this.run(async (at) => { if (!this.loaded || this.retired) return { removed: false } - if (serialize(this.value) !== at.value) return { removed: false } + // Nothing was on screen to compare against: the discard was asked of the item, not of + // a value, so whatever the load in front of it turned up is what it throws away. + if (at.loaded && serialize(this.value) !== at.value) 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 02ce5bc699..1faa1914b4 100644 --- a/frontend/src/lib/itemStore.test.ts +++ b/frontend/src/lib/itemStore.test.ts @@ -638,6 +638,26 @@ describe('item store: origins', () => { expect(rows.writes.at(-1)).toEqual({ path: 'u/me/r', value: null }) }) + it('discards the draft a read turns up when the discard was asked for before it', async () => { + const rows = fakeRows() + const read = deferred>() + const store = createItemStore(rows.port) + const { handle: item } = store.acquire( + { workspace: 'w', kind: 'resource', path: 'u/me/r' }, + { workspace: 'w', path: 'u/me/r' }, + adapter(() => read.promise) + ) + // The chat discards the draft while this editor's read is still out: nothing is on screen + // yet, so there is no value the discard could be measured against. + const discarding = item.discard() + read.resolve({ draft: { ...deployedRes, description: 'only a draft' } }) + + expect(await discarding).toEqual({ removed: true }) + expect(item.removed).toBe(true) + expect(item.value).toBeUndefined() + expect(rows.writes.at(-1)).toEqual({ path: 'u/me/r', value: null }) + }) + 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' }