Skip to content

Persist post drafts to localStorage#2

Open
mozzius wants to merge 5 commits into
mainfrom
samuel/persist-drafts
Open

Persist post drafts to localStorage#2
mozzius wants to merge 5 commits into
mainfrom
samuel/persist-drafts

Conversation

@mozzius

@mozzius mozzius commented May 31, 2026

Copy link
Copy Markdown
Owner

What

Mirrors everything you type in the post editor into localStorage so nothing is lost before it reaches the PDS, with UI that makes it unambiguous whether what you see is only cached locally or actually saved.

  • src/lib/drafts.ts (new) — DID-scoped keys (sh:draft:<did>:<rkey>, or …:new:<pub> for unsaved posts), a versioned PostDraft, and load/save/clear. In-post image blob refs round-trip through lexToJson/jsonToLex so restored uploads still save correctly (plain JSON.stringify mangles the CID into "[object Object]").
  • PostEditor.tsx — restores a draft on load (keeping the server body as the dirty baseline, so "dirty" correctly means local-only), debounced auto-persist (~600ms) with a pagehide flush, and clear-on-save/delete.
    • Status pill by the Save button: ● Draft saved locally · 2m ago (accent dot) vs ✓ Saved to your PDS (muted check) vs Saving….
    • Restore banner when a draft is recovered, with a Discard draft action and a stale warning if the published version changed underneath it.

Also in here

Consolidates the editor's ~13 scattered useStates into a single useReducer (postEditorReducer.ts) with named seed/restoreDraft/revert/draftCleared/markSaved/markClean actions. This is interleaved with the draft work in PostEditor.tsx, so it couldn't be split into a separate commit without a broken intermediate.

Known tradeoffs

  • Restored in-post images preview broken until the first save — the bsky CDN can't serve an uncommitted blob — but the blob ref is preserved, so the image saves correctly.
  • The cover-image File is not persisted (not serializable); the cover picker resets on reload.

Testing

  • pnpm typecheck
  • pnpm test ✓ (86 passing)
  • pnpm format:check

Manual: type → reload (draft restored, banner shown) → save (pill flips to "Saved to your PDS", key cleared); upload image → reload → publish (image survives); discard reverts to server.

🤖 Generated with Claude Code

@vercel

vercel Bot commented May 31, 2026

Copy link
Copy Markdown

The latest updates on your projects. Learn more about Vercel for GitHub.

Project Deployment Actions Updated (UTC)
standard.horse Ready Ready Preview, Comment May 31, 2026 11:33am

mirror the editor's working state (title, body, tags, path, format and
in-post image blob refs) into localStorage so nothing typed is lost before
it reaches the PDS. drafts are debounced, flushed on pagehide, restored on
load, and cleared on save/delete. a status pill ("draft saved locally" vs
"saved to your PDS") and a restore banner with a discard action make it clear
whether what you see is only cached locally or actually committed.

image blob refs round-trip through lexToJson/jsonToLex so restored uploads
still save correctly (plain JSON.stringify mangles the CID).

also consolidates the editor's ~13 useState fields into a single useReducer
(postEditorReducer.ts) with named seed/restore/revert/clear actions.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
on a fresh load the persist effect ran before the record finished loading,
when the form was still empty — so isDirty was false and the !isDirty branch
cleared the draft from localStorage a beat before the seeding effect could
restore it. gate persistence behind a `hydrated` flag set once the initial
seed/restore has run.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
surfaces the existing discardDraft logic as an always-available control,
shown whenever there are unsaved changes. confirms first, then clears the
local draft and reverts to the published version (or empties a new post).
the restore banner's "discard draft" link now routes through the same
confirm.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
isDirty diffs the body against seededBodyRef, a baseline set at load and
never moved on save — so after saving an existing post the body always read
as dirty, and the persist effect kept re-writing the draft we'd just cleared.

on save we now rebaseline seededBodyRef to the saved body and bump a
baselineTick so the isDirty memo (which can't depend on a ref) recomputes —
clearing the dirty state right away for body edits. a debounced write also
bails if the draft was cleared underneath it (flushRef nulled), so a pending
write can't resurrect a just-saved draft. metadata-only edits settle on the
background refetch.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
mounts the real PostEditor with the real query hooks, reducer, drafts module
and jsdom localStorage; mocks only the network layer (repo.ts), auth,
CodeMirror and analytics. covers the regressions we hit: draft persists on
type, restores after a reload, saving clears the dirty state + draft (and
doesn't resurrect it), discard reverts, and new-post drafts key by publication.

verified the suite has teeth — backing out the save-rebaseline fix or the
hydration gate each fails its corresponding test.

sets up vitest jsdom env (per-file), testing-library, and a plausible stub
alias (the package ships only a `module` field the test resolver can't load).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
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.

1 participant