Skip to content

fix(feed): drop deleted posts from the feed cache - #109

Merged
Adron merged 2 commits into
mainfrom
fix/delete-removes-from-feed-cache
Sep 17, 2026
Merged

Adron merged 2 commits into
mainfrom
fix/delete-removes-from-feed-cache

Conversation

@Adron

@Adron Adron commented Sep 16, 2026

Copy link
Copy Markdown
Member

Summary

Closes #108. Stacked on #107 (fix/feedview-inplace-row-updates), which must merge first — this
branch is based on it and the diff below is only the delete half. FeedView.deleteMessage removed
the row from the view's private @State private var messages working copy but never told the store,
so AppDataStore.feedMessages and the persisted feed cache kept the deleted post and served it back
on the next launch that read the cache. The server delete succeeded; only the local cache was wrong.
This is the mirror of the edit-staleness #107 fixed, and it reuses that PR's shape:
removeFeedMessage(id:) alongside updateFeedMessage(_:).

What's included

  • AppDataStore.removeFeedMessage(id:) — mutates the page, persists via the same saveFeedCache(),
    no-op on an unknown id (no removal and no feedRevision bump, matching updateFeedMessage).
    Two deliberate details:
    • It removes every copy of the id rather than one index. insertFeedMessage does not
      deduplicate — there is an existing test asserting that (test_insertFeedMessage_doesNotDuplicate ExistingMessage records count 2 for a repeated id) — so an index-based removal could leave a
      copy of the deleted post in the cache, which is the bug again.
    • The unknown-id no-op is not defensive padding: the feed page is a 50-row window on the timeline,
      so deleting a post that has already scrolled out of it is normal, not an error.
  • FeedView.deleteMessage calls it after the server delete succeeds, alongside the existing local
    removal. The optimistic UX is unchanged — the row still disappears the moment the delete returns.
  • FeedView.deleteMessage also drops the row from searchResults. The search-results list renders
    its own array and passes the same onDelete, so deleting from a search result previously left the
    row on screen until the search was re-run. Same bug class, one line, in the function being edited.

No new .swift files, so project.pbxproj is untouched and this branch cannot conflict with other
in-flight PRs.

Other delete paths audited

The issue names MessageDetailView, MessageThreadView and MessageReplyTree. None of them
deletes a message — the premise does not hold, and there was nothing to fix:

  • MessageReplyTree does not exist in the repo (no file, no type, no reference).
  • MessageDetailView — its actionsMenu is Share link + "Create from…"; its action row is reply,
    dig, repost, report, mute. No delete affordance.
  • MessageThreadView — root message plus replies, with reply/report/mute. No delete affordance.

Mechanically: APIClient.deleteMessage(id:) has exactly one production call site in the whole
app, FeedView.swift:537, and /api/messages/{id} DELETE is the only message-delete endpoint the
client has. Replies therefore cannot be deleted from the iOS app at all today.

Deliberately not touched, with reasons:

  • DMThreadView / MessagesInboxView — these call trashDM, a different domain (DMMessage,
    POST /api/dm/:id/trash, one-sided) that never enters feedMessages. Nothing to reconcile.
  • ListsView, DocumentsView, OrganizationsView, NotificationsView, EditProfileView — other
    entities; removeList/removeDocument already persist their own caches the same way.

Decision on replies

Decided: no cascade. The cache drops exactly the deleted id. Not "leave the orphans until the
next fetch" — there are no orphans to leave, because the feed cache cannot contain a reply:

  • Server side, GET /api/messages appends where = { ...where, parentId: null } (app/api/messages/ route.ts, commented "Only top-level messages (no replies in main feed)"), so a reply is never in a
    feed page and therefore never in feedMessages or the cache written from it.
  • Client side, the only other writer is ComposeView, which calls store.insertFeedMessage only
    when !isReply && !isRepost.

So a reply-cascade would be code no production path can reach. The backend's onDelete: Cascade on
model Message's self-relation handles the real cascade server-side, and the next fetch is
authoritative regardless.

Because "cannot happen" is worth pinning rather than asserting, the invariant is covered by tests
that construct the impossible state anyway and prove it degrades safely: removing a parent leaves a
reply row untouched with its fields intact, and the orphan survives a cache round-trip. The
round-trip case is the one with teeth — [Message] decodes all-or-nothing, so a row that failed to
decode would take the entire feed cache down with it. An orphan also cannot render broken: no view
in the app branches on Message.parentId (checked by grep across Views/), so a dangling parent id
renders as an ordinary standalone post.

Testing

  • Full suite, simulator 302E002E-9A0C-4F79-B54A-E9739A3EE582, serialized, E2E skipped, private
    DerivedData: 1181 tests, 0 failures (13 new; fix(feed): reconcile in-place row updates in the feed #107's baseline was 1168). No new warnings.
  • The 13 new cases in AppDataStoreTests, by the issue's acceptance criteria:
    • Removes from feedMessages — drops the row, keeps the others in order, removes every copy of a
      duplicated id.
    • Removes from the persisted cache — asserts the on-disk cache via a second DataCache reader,
      and test_removedMessage_staysDeletedAcrossARelaunch hydrates a fresh AppDataStore from
      that cache (onUserIdAvailable touches no network) and asserts the deleted post does not come
      back. That is the acceptance criterion, driven end to end.
    • Unknown id is a no-op — no removal, and no feedRevision bump either.
    • The replies decision — three cases, above.
    • Optimistic removal still happens — a pair. With the view's local removal, a subsequent
      FeedMerge leaves the row gone; without it, the merge puts the row back, which is why the
      local removal cannot be replaced by the store call (FeedMerge deliberately keeps rows the store
      does not hold — that is how pagination survives a merge). The second test is the regression guard
      for anyone who later "simplifies" the view line away.
  • Cache tests poll rather than sleep: saveFeedCache writes through a detached Task into an actor,
    so the file is not on disk when the call returns. Each seeds and asserts under a UUID user id and
    clears it in tearDown, so they neither collide nor leave JSON behind in the simulator.
  • Mutation-checked that the tests bite, twice:
    • Dropped saveFeedCache() from removeFeedMessage (the bug itself, half-fixed): 3 failures —
      exactly the three cache/persistence cases, reporting ["gone", "keep"] where ["keep"] was
      expected. The in-memory cases stayed green, which is the correct split.
    • Made removeFeedMessage a no-op (pre-fix behaviour): 9 of the 13 new cases failed. The 4
      survivors are the ones that assert an absence of change — the two unknown-id no-ops, the
      "orphan is left intact" case, and the "without the local removal the row stays" case — all of
      which a no-op satisfies by construction. Restored; full suite green (the 1181/0 above).

Not verified

  • Nothing was run on a device or simulator UI. These are unit tests over the store, its on-disk
    cache, and FeedMerge; that SwiftUI actually re-renders the list once messages changes is not
    asserted here. The user-visible proof — delete a post, kill the app, relaunch offline, confirm it
    stays gone — is covered at the data layer by test_removedMessage_staysDeletedAcrossARelaunch but
    not by a real launch.
  • The searchResults removal is view-local state with no test seam, so it is argued, not asserted.
  • The backend claims (parentId: null on the feed, onDelete: Cascade on model Message) were read
    from the backend source at ~/Codez/interlinedlist, not exercised against a live server from here.
  • digStates / locallyToggled keep their entry for a deleted id. Harmless — both are dictionaries
    read only for rows being rendered — and deliberately left alone rather than grown into a cleanup
    path this PR does not need.

🤖 Generated with Claude Code

`FeedView.deleteMessage` removed the row from its private working copy only,
so `AppDataStore.feedMessages` and the persisted feed cache kept the deleted
post and served it back on the next launch that read the cache.

`removeFeedMessage(id:)` mirrors `updateFeedMessage(_:)`: mutate the page,
persist the cache, no-op on an unknown id (and no revision bump). It removes
every copy of the id rather than one index, because `insertFeedMessage` does
not deduplicate.

No cascade to replies: the feed page is reply-free by construction — the
server filters `parentId: null` on `GET /api/messages`, and `ComposeView`
skips `insertFeedMessage` when replying — so a deleted parent has no replies
in this cache to remove. The backend's `onDelete: Cascade` settles the server
side.

The view's local removal stays and is load-bearing: `FeedMerge` keeps rows the
store does not hold, so the store call alone cannot evict a row the view
already holds. Search results now drop the row too, so a delete from the
search list disappears immediately.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_017bss5MgZa7Jvj2m9zdaUd1
Same two conflicts #107 hit, from #103's applyLinkMetadata landing on main:

- AppDataStore.swift: removeFeedMessage and applyLinkMetadata met at the shared
  trailing saveFeedCache(). Spliced into two complete methods, both bodies
  verified intact afterwards rather than assumed from the brace structure.
- AppDataStoreTests.swift: the makeMessage helper now carries content, parentId
  and linkMetadata, so call sites from all three branches compile.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_017bss5MgZa7Jvj2m9zdaUd1
@Adron
Adron merged commit bc09c32 into main Sep 17, 2026
1 check passed
@Adron
Adron deleted the fix/delete-removes-from-feed-cache branch September 17, 2026 09:20
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.

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

1 participant