diff --git a/frontend/src/lib/components/ResourceEditor.svelte b/frontend/src/lib/components/ResourceEditor.svelte index e087fadfba..68556ec704 100644 --- a/frontend/src/lib/components/ResourceEditor.svelte +++ b/frontend/src/lib/components/ResourceEditor.svelte @@ -128,8 +128,12 @@ } } + // Input that arrives before this workspace's handle exists cannot be an edit + // to its form — the form is not on screen yet — but it would open the gate, + // and the gating effect would then un-suspend the moment the fetch lands, + // in time for the schema's materialized values to POST as a draft. onUserInput(() => { - if (selected) userEdited[selected] = true + if (selected && selected in states) userEdited[selected] = true }) $effect(() => { diff --git a/frontend/src/lib/userDraftPrune.test.ts b/frontend/src/lib/userDraftPrune.test.ts index 215fb1d6cd..5ed0489ef6 100644 --- a/frontend/src/lib/userDraftPrune.test.ts +++ b/frontend/src/lib/userDraftPrune.test.ts @@ -139,6 +139,23 @@ describe('pruneMeaninglessDrafts', () => { expect(discardDraft).not.toHaveBeenCalled() }) + it('does not count, or seal the pass on, a delete the syncer failed to send', async () => { + listDrafts.mockResolvedValue([row()]) + getDraftDiffValues.mockResolvedValue(diff()) + // The discard's own POST is what fails, so the state flips during it. + discardDraft.mockImplementation(async () => { + syncState = 'failed' + return { success: true } + }) + await pruneMeaninglessDrafts('main', 'me@x.dev') + expect(sendUserToast).not.toHaveBeenCalled() + discardDraft.mockResolvedValue({ success: true }) + // Sentinel unwritten, so the next mount retries the draft left behind. + syncState = 'none' + await pruneMeaninglessDrafts('main', 'me@x.dev') + expect(discardedPaths()).toEqual(['u/me/r', 'u/me/r']) + }) + it('runs once per workspace and user', async () => { listDrafts.mockResolvedValue([row()]) getDraftDiffValues.mockResolvedValue(diff()) diff --git a/frontend/src/lib/userDraftPrune.ts b/frontend/src/lib/userDraftPrune.ts index 679a60eb1f..702c067326 100644 --- a/frontend/src/lib/userDraftPrune.ts +++ b/frontend/src/lib/userDraftPrune.ts @@ -124,6 +124,7 @@ export async function pruneMeaninglessDrafts(workspace: string, userKey: string) }) let discarded = 0 + let failed = 0 for (const c of empty) { // Re-check: the reads above took a while, and the user may have opened // this item in the meantime. @@ -131,19 +132,24 @@ export async function pruneMeaninglessDrafts(workspace: string, userKey: string) const q = { workspace, itemKind: c.kind, path: c.path } UserDraftDbSyncer.recordRemoteSync(q, c.createdAt) const res = await discardDraft(c.kind, c.path, workspace, false, c.legacy, false) - // A refused delete surfaces as a conflict, not an error: the row moved - // past the baseline, so it is no longer the draft we judged empty. - if (res.success && !UserDraftDbSyncer.getConflict(q).conflict) discarded++ + // Neither outcome throws: the syncer swallows an HTTP failure into its + // per-key state, and a delete refused for a moved row comes back as a + // conflict. So `success` alone says nothing about whether the row went. + if (!res.success || UserDraftDbSyncer.getState(q).state === 'failed') failed++ + else if (!UserDraftDbSyncer.getConflict(q).conflict) discarded++ } if (discarded > 0) { invalidateWorkspaceDrafts(workspace) sendUserToast(`Cleared ${discarded} draft${discarded > 1 ? 's' : ''} that carried no changes`) } - // Only after a completed pass: a run that threw retries on the next mount. - try { - localStorage.setItem(sentinel, new Date().toISOString()) - } catch { - // Nothing to do — the pass is idempotent, it just runs again. + // Only once every deletion this pass attempted actually landed. A draft + // left behind by a failed delete would otherwise never be revisited. + if (failed === 0) { + try { + localStorage.setItem(sentinel, new Date().toISOString()) + } catch { + // Nothing to do — the pass is idempotent, it just runs again. + } } } catch { // Fire-and-forget from the layout: a workspace whose draft list can't be