Skip to content

Bug: deleting a feed post leaves it in the persisted cache — reappears after relaunch #108

Closed

Description

@Adron

Found while implementing #105 (see PR #107), which fixed the same class of bug in the other
direction.

The problem

FeedView.deleteMessage removes the message from the view's private @State private var messages
working copy, but never from AppDataStore.feedMessages — and therefore never from the persisted
feed cache that AppDataStore writes.

Consequence: the row disappears from the screen, and then comes back — on the next launch that
reads the cache, or any path that re-seeds the view from the store — until a live fetch happens to
replace the page. The server delete succeeds; only the local cache is wrong.

This is the mirror of the staleness #107 fixed for edits, where an edited post updated only the
view's copy and left the store holding the old text.

Why it wasn't fixed in #107

#107 was scoped to in-place row updates. Delete is a different operation with its own question
(below), and folding it in would have mixed two behaviours in one PR.

Build this

  • AppDataStore.removeFeedMessage(id:) — mirroring the updateFeedMessage(_:) added in fix(feed): reconcile in-place row updates in the feed #107,
    persisting the cache the same way, and a safe no-op for an unknown id.
  • Call it from FeedView.deleteMessage alongside the existing local removal.
  • Decide the reply case: deleting a parent — does the cache need to drop its replies too, or
    does the server cascade and the next fetch settle it? Message has parentId and the backend
    cascades on delete (onDelete: Cascade in model Message), so leaving orphans in the cache
    until the next fetch may be acceptable. Worth one deliberate decision rather than an accident.
  • Check the other delete paths for the same gap — MessageDetailView, MessageThreadView and
    MessageReplyTree all delete messages.

Acceptance criteria

  • A deleted post stays deleted across a relaunch with no network.
  • Unit test: delete removes from feedMessages and from the persisted cache; unknown id is a
    no-op.
  • No regression to the optimistic-removal UX (the row still disappears immediately).

Files: Views/FeedView.swift, Services/AppDataStore.swift, and the other delete call sites.

Activity

  1. added
    bugSomething isn't working
    ws:papercutsW8 — small parity papercuts
    on Sep 16, 2026
  2. Adron commented on Sep 16, 2026

    @Adron
    MemberAuthor

    Implemented in #109 (stacked on #107, which must merge first). Closing here — further work tracks in
    the PR.

    Correcting this issue: my audit list was wrong

    I wrote:

    Check the other delete paths for the same gap — MessageDetailView, MessageThreadView and
    MessageReplyTree all delete messages.

    None of that is true. Verified:

    • MessageReplyTree does not exist in this repo.
    • MessageDetailView.swift and MessageThreadView.swift contain zero occurrences of "delete".
    • APIClient.deleteMessage(id:) has exactly one production call site app-wide:
      Views/FeedView.swift:536.

    A reply cannot be deleted from the iOS app at all today.

    The names came from the web codebase — components/messages/MessageReplyTree.tsx and
    MessageDetailClient.tsx, which I had grepped while investigating #76 — and I wrote them into this
    issue as if they were iOS files without re-checking. My error, and worth flagging because an audit
    list that looks authoritative is worse than no list.

    What the audit did find

    FeedView's search-results list passes the same onDelete, and deleteMessage never removed
    the row from searchResults — so deleting from a search result left the row on screen. Real, same
    bug class, fixed in #109.

    DMThreadView / MessagesInboxView deletes are trashDM on DMMessage — a different domain that
    never enters feedMessages. Correctly out of scope.

    The replies decision

    No cascade — remove exactly the named id, and the reasoning is stronger than "orphans are
    acceptable": the feed cache cannot hold a reply. GET /api/messages appends parentId: null
    ("Only top-level messages (no replies in main feed)"), and the only other cache writer, ComposeView,
    inserts only when !isReply && !isRepost. A cascade would be unreachable code; the backend's
    onDelete: Cascade handles the real thing.

    Three tests construct the impossible state anyway — including a cache round-trip, since [Message]
    decodes all-or-nothing and one malformed row would take the entire feed cache with it.

    Two implementation details worth keeping

    • The optimistic local removal is load-bearing, not cosmetic. FeedMerge (from fix(feed): reconcile in-place row updates in the feed #107)
      deliberately keeps rows the store doesn't hold — that is how paginated pages survive a merge — so
      store.removeFeedMessage alone can never evict a row the view already holds. fix(feed): drop deleted posts from the feed cache #109 adds a test that
      fails loudly if a future "simplification" drops the view-side line.
    • removeAll, not remove(at:) — insertFeedMessage does not deduplicate (there is an existing
      test asserting count 2 for a repeated id), so an index-based removal would leave a copy behind.

    Unrelated latent issue noted, not fixed

    EditMessageView.swift:132 reconstructs its Message with parentId: nil hardcoded. Harmless today
    since feed rows are all top-level, but a field-drop waiting to happen if replies ever become
    editable — relevant to #76.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Assignees

No one assigned

    Labels

    bugSomething isn't workingws:papercutsW8 — small parity papercuts

    Type

    No type

    Projects

    No projects

      Milestone

      No milestone

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions