fix: close the gate's load-time window and stop sealing a failed sweep

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01ScVgqGpuyDMWdzVNPm7Q5f
This commit is contained in:
Diego Imbert
2026-09-04 12:19:23 +02:00
co-authored by Claude Opus 5
parent c80002538d
commit 4e91b4e2f6
3 changed files with 36 additions and 9 deletions
@@ -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(() => {
+17
View File
@@ -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())
+14 -8
View File
@@ -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