Skip to content

fix(drafts): never create or sync a content-less draft; sweep existing empties - #748

Merged
spe1020 merged 3 commits into
mainfrom
fix/empty-draft-guard
Sep 20, 2026
Merged

spe1020 merged 3 commits into
mainfrom
fix/empty-draft-guard

Conversation

@spe1020

@spe1020 spe1020 commented Sep 20, 2026

Copy link
Copy Markdown
Contributor

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:

  1. Blank drafts were created and synced on open. Every "Write article" entry point (create menu, Reads header, Drafts page) called 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.
  2. The recipe store merged every remote draft regardless of type. syncDrafts took the whole NIP-37 result set without checking draftType, 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.
  • Article store — a new draft is held in a pendingDraft store (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.
  • Recipe storesaveDraft returns draftId: null for a content-less new draft instead of allocating an id. Both 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 published back as recipes under the same d-tag (which would replace the article on the relay).
  • NIP-37 publishers — refuse content-less drafts as a last line of defence, so no code path can turn an empty draft into a relay write.
  • Cleanup — both stores sweep content-less drafts on load and after every sync, and tombstone the ones that reached a relay. No user action required.
  • Article sync no longer republishes every local draft when the fetch returns nothing (which also happens on a fetch timeout).

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.ts updated: a new draft is pending, not stored.

Full suite: 143 files, 2020 tests pass. svelte-check: 0 errors.

Not in this PR (follow-ups)

  • Recipe sync republishes every draft on every run. mergeDrafts compares millisecond local updatedAt against second-precision remote timestamps, and saveLocalDrafts strips syncStatus, so every recipe draft on the device is re-encrypted, re-signed and republished on each mount of /create or the profile drafts tab plus every 5 minutes after. This is the real relay write amplifier and deserves its own PR.
  • Article manual save publishes twice (immediate plus a 5 s debounced copy).
  • Deleted drafts resurrect from other devices because the merge treats a local draft missing from the relay as new and republishes it. Pre-existing.

🤖 Generated with Claude Code

…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>
@cloudflare-workers-and-pages

cloudflare-workers-and-pages Bot commented Sep 20, 2026

Copy link
Copy Markdown

Deploying with  Cloudflare Workers  Cloudflare Workers

The latest updates on your project. Learn more about integrating Git with Workers.

Status Name Latest Commit Updated (UTC)
❌ Deployment failed
View logs
zapcooking-frontend fe73356 Sep 20 2026, 10:48 PM

@cloudflare-workers-and-pages

cloudflare-workers-and-pages Bot commented Sep 20, 2026

Copy link
Copy Markdown

Deploying with  Cloudflare Workers  Cloudflare Workers

The latest updates on your project. Learn more about integrating Git with Workers.

Status Name Latest Commit Updated (UTC)
❌ Deployment failed
View logs
frontend fe73356 Sep 20 2026, 10:49 PM

@cloudflare-workers-and-pages

cloudflare-workers-and-pages Bot commented Sep 20, 2026

Copy link
Copy Markdown

Deploying frontend with  Cloudflare Pages  Cloudflare Pages

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

View logs

Copilot AI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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 High severity

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.

Comment on lines +75 to +80
// 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);
}

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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".

Comment thread src/lib/draftStore.ts
Comment on lines +329 to +331
const localDrafts = sweepEmptyDrafts(rawLocalDrafts);
if (localDrafts.length !== rawLocalDrafts.length) {
saveLocalDrafts(localDrafts);

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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".

spe1020 and others added 2 commits September 20, 2026 18:39
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>
@spe1020
spe1020 merged commit 62aaa13 into main Sep 20, 2026
6 of 8 checks passed
@spe1020
spe1020 deleted the fix/empty-draft-guard branch September 20, 2026 22:49
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants