fix(feed): drop deleted posts from the feed cache - #109
Merged
Merged
Conversation
`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
This was referenced Sep 16, 2026
Adron
changed the base branch from
fix/feedview-inplace-row-updates
to
main
September 17, 2026 08:59
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
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Summary
Closes #108. Stacked on #107 (
fix/feedview-inplace-row-updates), which must merge first — thisbranch is based on it and the diff below is only the delete half.
FeedView.deleteMessageremovedthe row from the view's private
@State private var messagesworking copy but never told the store,so
AppDataStore.feedMessagesand the persisted feed cache kept the deleted post and served it backon 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:)alongsideupdateFeedMessage(_:).What's included
AppDataStore.removeFeedMessage(id:)— mutates the page, persists via the samesaveFeedCache(),no-op on an unknown id (no removal and no
feedRevisionbump, matchingupdateFeedMessage).Two deliberate details:
insertFeedMessagedoes notdeduplicate — there is an existing test asserting that (
test_insertFeedMessage_doesNotDuplicate ExistingMessagerecords count 2 for a repeated id) — so an index-based removal could leave acopy of the deleted post in the cache, which is the bug again.
so deleting a post that has already scrolled out of it is normal, not an error.
FeedView.deleteMessagecalls it after the server delete succeeds, alongside the existing localremoval. The optimistic UX is unchanged — the row still disappears the moment the delete returns.
FeedView.deleteMessagealso drops the row fromsearchResults. The search-results list rendersits own array and passes the same
onDelete, so deleting from a search result previously left therow on screen until the search was re-run. Same bug class, one line, in the function being edited.
No new
.swiftfiles, soproject.pbxprojis untouched and this branch cannot conflict with otherin-flight PRs.
Other delete paths audited
The issue names
MessageDetailView,MessageThreadViewandMessageReplyTree. None of themdeletes a message — the premise does not hold, and there was nothing to fix:
MessageReplyTreedoes not exist in the repo (no file, no type, no reference).MessageDetailView— itsactionsMenuis 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 wholeapp,
FeedView.swift:537, and/api/messages/{id}DELETE is the only message-delete endpoint theclient has. Replies therefore cannot be deleted from the iOS app at all today.
Deliberately not touched, with reasons:
DMThreadView/MessagesInboxView— these calltrashDM, a different domain (DMMessage,POST /api/dm/:id/trash, one-sided) that never entersfeedMessages. Nothing to reconcile.ListsView,DocumentsView,OrganizationsView,NotificationsView,EditProfileView— otherentities;
removeList/removeDocumentalready 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:
GET /api/messagesappendswhere = { ...where, parentId: null }(app/api/messages/ route.ts, commented "Only top-level messages (no replies in main feed)"), so a reply is never in afeed page and therefore never in
feedMessagesor the cache written from it.ComposeView, which callsstore.insertFeedMessageonlywhen
!isReply && !isRepost.So a reply-cascade would be code no production path can reach. The backend's
onDelete: Cascadeonmodel Message's self-relation handles the real cascade server-side, and the next fetch isauthoritative 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 todecode 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 acrossViews/), so a dangling parent idrenders as an ordinary standalone post.
Testing
302E002E-9A0C-4F79-B54A-E9739A3EE582, serialized, E2E skipped, privateDerivedData: 1181 tests, 0 failures (13 new; fix(feed): reconcile in-place row updates in the feed #107's baseline was 1168). No new warnings.
AppDataStoreTests, by the issue's acceptance criteria:feedMessages— drops the row, keeps the others in order, removes every copy of aduplicated id.
DataCachereader,and
test_removedMessage_staysDeletedAcrossARelaunchhydrates a freshAppDataStorefromthat cache (
onUserIdAvailabletouches no network) and asserts the deleted post does not comeback. That is the acceptance criterion, driven end to end.
feedRevisionbump either.FeedMergeleaves the row gone; without it, the merge puts the row back, which is why thelocal removal cannot be replaced by the store call (
FeedMergedeliberately keeps rows the storedoes 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.
saveFeedCachewrites through a detachedTaskinto 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.saveFeedCache()fromremoveFeedMessage(the bug itself, half-fixed): 3 failures —exactly the three cache/persistence cases, reporting
["gone", "keep"]where["keep"]wasexpected. The in-memory cases stayed green, which is the correct split.
removeFeedMessagea no-op (pre-fix behaviour): 9 of the 13 new cases failed. The 4survivors 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
cache, and
FeedMerge; that SwiftUI actually re-renders the list oncemessageschanges is notasserted 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_staysDeletedAcrossARelaunchbutnot by a real launch.
searchResultsremoval is view-local state with no test seam, so it is argued, not asserted.parentId: nullon the feed,onDelete: Cascadeonmodel Message) were readfrom the backend source at
~/Codez/interlinedlist, not exercised against a live server from here.digStates/locallyToggledkeep their entry for a deleted id. Harmless — both are dictionariesread 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