fix(drafts): see the settle through, so a rename leaves nothing at the path it left

The settle sent its delete and returned on the first flush, but the form stays
editable across that request: an edit made then parks a write behind it, and once
the callers re-key to the new path that write lands as a draft under a path the
item no longer occupies, with no editor on it. The delete is re-sent while the
key refuses to settle on it — bounded, since a form still being typed into can
always add one more.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01KcyBRxE8j3ujCYkum9KrC4
This commit is contained in:
Diego Imbert
2026-09-06 18:48:07 +02:00
co-authored by Claude Opus 5
parent e1859f504d
commit 239e19e7c6
2 changed files with 38 additions and 5 deletions
+11 -5
View File
@@ -285,12 +285,18 @@ export async function settleDraftAfterWrite<V>(
savedPath: string,
opts?: UserDraftOptions
): Promise<void> {
if (savedPath !== fromPath || draftValuesEqual(live, written)) {
if (savedPath === fromPath && !draftValuesEqual(live, written)) return
// Sent before this resolves, not left on the keystroke debounce: callers report
// the write and remount on it, and both read a cell this has to have finished
// resolving — including through a frame with a draft store of its own.
//
// Re-sent while the key is not settled on that delete: the form stays editable
// across the request, and an edit made then parks a write behind it that would
// put a draft back — under a path the item has left, once the callers re-key.
// Bounded, because a form still being typed into can always add one more.
for (let attempt = 0; attempt < 3; attempt++) {
UserDraft.discard(itemKind, fromPath, written, opts)
// Sent before this resolves, not left on the keystroke debounce: callers
// report the write and remount on it, and both read a cell this has to have
// finished resolving — including through a frame with a draft store of its own.
await flushDraftWrites(itemKind, fromPath, opts)
if (await flushDraftDelete(itemKind, fromPath, opts)) return
}
}
@@ -1,13 +1,25 @@
import { describe, it, expect, beforeEach, vi } from 'vitest'
const discard = vi.fn()
// Whether the key ends up settled on the delete after a flush — false stands for a
// form that keeps queueing writes behind it.
let settles = true
vi.mock('./gen', () => ({ DraftService: { updateDraft: vi.fn() } }))
vi.mock('./gen/core/OpenAPI', () => ({ OpenAPI: { BASE: '' } }))
vi.mock('./localDraftHints.svelte', () => ({ setLocalDraftHint: vi.fn() }))
vi.mock('./userDraftDbSyncer.svelte', () => ({
UserDraftDbSyncer: {
save: vi.fn(),
flush: vi.fn(async () => {}),
lastLandedWasDelete: vi.fn(() => settles),
getState: vi.fn(() => ({ state: 'none' }))
}
}))
import { settleDraftAfterWrite, UserDraft } from './userDraft.svelte'
beforeEach(() => {
settles = true
discard.mockClear()
vi.spyOn(UserDraft, 'discard').mockImplementation(discard as any)
})
@@ -53,6 +65,21 @@ describe('settleDraftAfterWrite', () => {
expect(discard).toHaveBeenCalledWith('variable', 'u/me/a', sent, OPTS)
})
// The form stays editable across the delete's own request, so an edit made then
// parks a write that would put the draft back — under a path the item has left,
// once the callers re-key. The delete is re-sent while that is the case, and
// bounded, because a form being typed into can always add one more.
it('re-sends the delete while the key will not settle on it, and gives up bounded', async () => {
settles = false
await settleDraftAfterWrite('variable', sent, { ...sent }, 'u/me/a', 'u/me/b', OPTS)
expect(discard).toHaveBeenCalledTimes(3)
})
it('sends it once when the key settles', async () => {
await settleDraftAfterWrite('variable', sent, { ...sent }, 'u/me/a', 'u/me/b', OPTS)
expect(discard).toHaveBeenCalledTimes(1)
})
it('compares through the draft normalization, so a nested edit is not missed', () => {
const written = { path: 'u/me/a', args: { host: 'h' } }
settleDraftAfterWrite(