diff --git a/frontend/src/lib/userDraft.svelte.ts b/frontend/src/lib/userDraft.svelte.ts index 501a7576f3..17ae1d0d74 100644 --- a/frontend/src/lib/userDraft.svelte.ts +++ b/frontend/src/lib/userDraft.svelte.ts @@ -285,12 +285,18 @@ export async function settleDraftAfterWrite( savedPath: string, opts?: UserDraftOptions ): Promise { - 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 } } diff --git a/frontend/src/lib/userDraftSettleAfterWrite.test.ts b/frontend/src/lib/userDraftSettleAfterWrite.test.ts index 039629f8da..7c3407e492 100644 --- a/frontend/src/lib/userDraftSettleAfterWrite.test.ts +++ b/frontend/src/lib/userDraftSettleAfterWrite.test.ts @@ -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(