fix(drafts): never create or sync a content-less draft; sweep existing empties - #748
Conversation
…g empties Opening the article editor persisted an empty draft and published it to relays before a single keystroke, and the recipe draft store merged every remote NIP-37 draft regardless of type, so those blanks surfaced in the profile drafts tab as "Untitled Draft" with a synced badge. - draftContent.ts: shared predicates — a draft has content when it has a title, body text or embedded media, attachment, or metadata. - Article store: a new draft is held as `pendingDraft` (still the current draft for the editor) and only enters storage/sync once it has content. Emptied stored drafts stay on disk but are not pushed. - Recipe store: saveDraft returns `draftId: null` for a content-less new draft instead of allocating one; create pages show a hint instead of rewriting the URL. - Recipe sync filters remote drafts to draftType 'recipe', and drops stale local copies of article drafts so they can never be republished as recipes under the same d-tag. - NIP-37 publishers refuse content-less drafts as a last line of defence. - Cleanup: both stores sweep content-less drafts on load and after every sync, tombstoning the ones that reached a relay. - Article sync no longer republishes every local draft when the fetch returns nothing. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Deploying with
|
| Status | Name | Latest Commit | Updated (UTC) |
|---|---|---|---|
| ❌ Deployment failed View logs |
zapcooking-frontend | fe73356 | Sep 20 2026, 10:48 PM |
Deploying with
|
| Status | Name | Latest Commit | Updated (UTC) |
|---|---|---|---|
| ❌ Deployment failed View logs |
frontend | fe73356 | Sep 20 2026, 10:49 PM |
Deploying frontend with
|
| Latest commit: |
fe73356
|
| Status: | ✅ Deploy successful! |
| Preview URL: | https://f0b268d1.frontend-hvd.pages.dev |
| Branch Preview URL: | https://fix-empty-draft-guard.frontend-hvd.pages.dev |
There was a problem hiding this comment.
Copilot review overview
🟡 Changes recommended
Critical draft-resurrection issues and the autosave publication issue remain unresolved.
Get a fresh assessment by requesting another Copilot review.
Review effort: Lite
Findings: 2
Open (2)
What changed in this PR
This PR prevents content-less drafts from being persisted or published, cleans existing empties, and separates article and recipe synchronization.
Changes:
- Adds content detection and NIP-37 publication guards.
- Introduces pending article drafts and nullable recipe saves.
- Adds filtering, cleanup, tombstones, UI handling, and tests.
| File | Summary |
|---|---|
src/routes/create/gated/+page.svelte |
Handles empty save results. |
src/routes/create/+page.svelte |
Avoids URL updates for empty drafts. |
src/lib/nip37DraftService.ts |
Blocks content-less relay publications. |
src/lib/lazyComponentLoader.test.ts |
Updates pending-draft expectations. |
src/lib/draftStore.ts |
Adds recipe filtering and cleanup; critical stale-draft resurrection remains possible during load-time sweeping. |
src/lib/draftStore.test.ts |
Tests recipe draft safeguards and synchronization. |
src/lib/draftContent.ts |
Provides shared content predicates. |
src/lib/draftContent.test.ts |
Tests content detection edge cases. |
src/components/reads/articleDraftStore.ts |
Manages pending articles; critical resurrection and moderate autosave publication issues remain unresolved. |
src/components/reads/articleDraftStore.test.ts |
Tests article draft behavior and cleanup. |
package.json |
Bumps the application version. |
💡 Add a code-review agent skill or configure MCP servers for context-aware, tailored reviews. Learn more in the docs.
| // Sweep empties left behind by builds that persisted before content | ||
| const { kept, empties } = partitionEmptyDrafts(parsed); | ||
| if (empties.length > 0) { | ||
| console.log(`[ArticleDrafts] Swept ${empties.length} empty draft(s)`); | ||
| persistDrafts(kept); | ||
| } |
There was a problem hiding this comment.
Fixed in 0d79529. Emptying a stored article draft is now a delete: it is removed from localStorage, a tombstone is published via deleteDraftRemote(id, 'article'), and the draft returns to pendingDraft so the open editor keeps the same id. There is no longer an empty-but-stored state for the load sweep to discard, so the sweep only ever touches legacy empties. Regression test: "an emptied draft is not resurrected by a stale relay copy on reload".
| const localDrafts = sweepEmptyDrafts(rawLocalDrafts); | ||
| if (localDrafts.length !== rawLocalDrafts.length) { | ||
| saveLocalDrafts(localDrafts); |
There was a problem hiding this comment.
Fixed in 0d79529. saveDraft with no content on an existing id now calls deleteDraft (local removal, cancelPendingPublish, relay tombstone) and returns { draftId: null, deletedId }; the create pages clear currentDraftId and the ?draft= param. Lines 402–403 are unchanged on purpose: those are local copies of article drafts leaked into the recipe store, and tombstoning them as recipes would be wrong, so they are dropped and left to the article store. Regression test: "an emptied draft does not come back from a stale relay copy on the next sync".
Review finding: an emptied draft was kept on disk without a relay tombstone, and the load-time sweep dropped that empty record before the first sync, so the next fetch restored the old contentful relay copy. Emptying an existing draft is now a delete in both stores: local removal plus a relay tombstone when sync is on. The recipe store reports it via `deletedId` so the create pages clear the id and the ?draft= param; the article store returns the draft to pending so the open editor keeps working on the same id. Regression tests cover the reload-then-sync path. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>

Problem
A dev reported eight "Untitled Draft" entries, all stamped the same minute, each with a synced badge. Real drafts were confirmed intact, so this is noise, not data loss.
Two defects combined to produce it:
openNewDraft, which persisted an empty draft to localStorage and published it to relays before any keystroke. No timer or reactive loop is involved; each tap produced one draft and two relay writes.syncDraftstook the whole NIP-37 result set without checkingdraftType, so blank article drafts landed in the profile drafts tab as "Untitled Draft" recipes with a synced badge. That is where the screenshot came from.Drafts never touch KV. All draft writes are signed kind 31234 relay events.
Fix
src/lib/draftContent.ts— shared predicates. A draft has content when it has a title, body text or embedded media, an attachment, or metadata. Whitespace and blank list rows do not count.pendingDraftstore (still the editor's current draft) and only enters storage and sync once it has content. An emptied stored draft stays on disk but is not pushed.saveDraftreturnsdraftId: nullfor a content-less new draft instead of allocating an id. Both create pages show a hint instead of rewriting the URL.draftType === 'recipe', and drops stale local copies of article drafts so they can never be published back as recipes under the same d-tag (which would replace the article on the relay).Tests
draftContent.test.ts— predicate coverage per field, whitespace, blank rows, image-only bodies, editor's empty paragraph markup.articleDraftStore.test.ts— opening the editor eight times leaves zero drafts and zero publishes; first content promotes and syncs; emptied drafts persist without sync; load and sync sweeps with tombstones; no republish on empty fetch.draftStore.test.ts— guard on new and stale-id saves; article drafts ignored on sync; leaked local article copies dropped rather than republished as recipes; sweep on init and sync with tombstones for remote empties only.lazyComponentLoader.test.tsupdated: a new draft is pending, not stored.Full suite: 143 files, 2020 tests pass.
svelte-check: 0 errors.Not in this PR (follow-ups)
mergeDraftscompares millisecond localupdatedAtagainst second-precision remote timestamps, andsaveLocalDraftsstripssyncStatus, so every recipe draft on the device is re-encrypted, re-signed and republished on each mount of/createor the profile drafts tab plus every 5 minutes after. This is the real relay write amplifier and deserves its own PR.🤖 Generated with Claude Code