fix(feed): reconcile in-place row updates in the feed - #107
Merged
Merged
Conversation
FeedView mirrors store.feedMessages into a private @State copy and reconciled it on .onChange(of: store.feedMessages.count), merging only ids it did not already hold. An edit to an existing row moved neither the trigger nor the merge, so the store and its cache were right while the rendered feed stayed stale until a full reload. Observing the array itself does not fix it: Message's Equatable is identity-based (id only), so [Message] compares equal after a row's content is replaced. AppDataStore now carries feedRevision, bumped from a didSet on feedMessages, which moves for any mutation including an in-place one, and the merge moves into FeedMerge.merge(existing: incoming:) — a pure function that replaces matched ids in place (keeping their position, and with it the scroll position), prepends unseen ids, and keeps rows the store never saw, which is what pagination appends. The first-load path, the showOnlyMine/tagFilter guards, the feedLoading path and loadMore() are untouched, and the merge issues no requests. digStates/locallyToggled stay as they are; the re-seed now covers replaced rows too, still guarded by locallyToggled so an optimistic dig survives. AppDataStore.updateFeedMessage replaces one row by id (unknown id is a no-op) and persists the feed cache; FeedView's edit callback calls it so an edited post no longer leaves a stale row behind in the cache. 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
Two conflicts, both from #103 landing on main and touching the same places: - AppDataStore.swift: this branch adds updateFeedMessage(_:), #103 added applyLinkMetadata(_:toMessageId:), and the two bodies met at the shared trailing saveFeedCache(). Spliced into two complete methods rather than a blind union, which would have fused them into one broken function. - AppDataStoreTests.swift: both sides added a makeMessage helper — this branch with a `content:` parameter, #103 with `linkMetadata:`. Merged into a single helper carrying both, so every call site on both sides still compiles. Worth noting the two features compose the way #105 intended: feedRevision's didSet now fires for applyLinkMetadata too, so the link-preview backfill is exactly the in-place update this branch makes the feed observe. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_017bss5MgZa7Jvj2m9zdaUd1
Adron
added a commit
that referenced
this pull request
Sep 17, 2026
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 #105.
FeedViewcould not see an in-place row update: it mirrorsstore.feedMessagesinto aprivate
@State private var messagesand reconciled on.onChange(of: store.feedMessages.count),merging only ids it did not already hold. Editing an existing row moves neither the trigger nor the
merge, so the store and the persisted cache were correct while the rendered feed stayed stale until a
full reload.
The reconciliation now responds to content: the trigger is a store-side revision that moves for any
feed mutation, and the merge replaces matched ids in place instead of skipping them. The private
mirror stays — it is not a mirror, it is the view's working copy, and pagination appends pages the
store never sees.
Surprise worth knowing: the cheapest option in the issue — "observe the array itself
(
MessageisHashable)" — does not work.Message's conformance is deliberatelyidentity-based (
Models/Message.swift:static func == (lhs, rhs) { lhs.id == rhs.id }, so it candrive
navigationDestination(item:)), which means[Message]compares equal after a row'scontent is replaced and
.onChange(of: store.feedMessages)would never fire. Making==content-based would have fixed the trigger, but it changes the semantics of a type used app-wide and needs
Equatableon four nested payload types, so it was rejected in favour of the revision counter.What's included
Services/FeedMerge.swift—FeedMerge.merge(existing:incoming:), pure and testable: matched idsare replaced in place (keeping their position, and with it the scroll position), unseen ids are
prepended newest-first, rows the store does not hold are kept. Returns the merged array plus the
rows it inserted or replaced, so the caller knows what derived state to re-seed.
AppDataStore.feedRevision—@Published, bumped from adidSetonfeedMessages, so it movesfor an in-place row edit as well as an insert/replace. This is the trigger
FeedViewnow observes.AppDataStore.updateFeedMessage(_:)— replaces one row by id (unknown id: no-op) and persists thefeed cache. The store-side mutation the fix exists to serve, and what the acceptance test drives.
FeedView—.onChange(of: store.feedRevision)replaces.onChange(of: store.feedMessages.count);the else-branch merge becomes the
FeedMergecall, still guarded by!showOnlyMine && tagFilter == niland still returning early when nothing changed.
FeedView's edit callback also callsstore.updateFeedMessage(updated), so an edited post nolonger leaves a stale row in the store and its cache (it previously wrote only to the view's copy).
This is the production call site for the new store method.
FeedMergeTests(12) and 9 new cases inAppDataStoreTests. New files hand-slottedinto
project.pbxproj(no gem re-sort).Approach and why
Three options were on the table; this is the middle one, plus the trigger it needs.
loadMore()appendspages the store never holds, and
loadMessages()replaces it wholesale for theshowOnlyMine/tagFilterviews. Dropping it would mean moving pagination and filtering into the store.in the view's lifecycle; the only behavioural difference is that a row the store re-sends now wins
over the held copy, which is the bug. The revision is O(1) to compare, unlike a deep array diff on
every body evaluation of the app's busiest screen.
The merge moved out of the view because it is otherwise unreachable from a test — the same reason
feedTruncatedis a file-level function inFeedView.swifttested byFeedTruncationTests.Deliberately not in this PR (noted in #105, left as follow-ups):
digStates/locallyToggledinto the merged row. It does not fall out for free —locallyToggledprotects an optimistic dig from being overwritten by a later server copy, which isa policy the merge does not encode. What did fall out: the dig-state re-seed now also covers
replaced rows, still skipping locally toggled ids, so a server-side dig correction becomes visible.
FeedView.deleteMessageremoves the row from the working copy only, so the store and its cachekeep a deleted post until the next refresh. Same bug class, different direction; it needs a
removeFeedMessageand its own tests.No-regression checks
head, exactly as before. Covered by
test_merge_replacedRow_keepsItsPosition.!syncedFromStorebranch is untouched, as isapplyInitialState()and the.onChange(of: store.feedLoading)fallback.(
test_merge_paginatedRowsNotInIncoming_areKept,test_storeRowEdit_keepsPaginatedRowsTheStoreNeverSaw).loadMore()is unchanged.guard !showOnlyMine && tagFilter == nilis unchanged and still first.loadMessages()is still reached only fromthe filter/user-id
onChanges,refreshable, and the retry button.(
test_merge_existingRowWithNewContent_isNotDuplicated).initDigStatesstill skipslocallyToggledids.Testing
E21BC7D1-B6EE-4677-8BE3-322F26A4D3D6, serialized, E2E skipped, privateDerivedData: 1168 tests, 0 failures (21 new). No new warnings.
behaviour): 6 failures, exactly the intended ones (4 in
FeedMergeTests, 2 inAppDataStoreTests). Restored, full suite green.didSetthat bumpsfeedRevision: 3 failures, the two"revision moves" cases and the end-to-end store-edit case. Restored, full suite green.
Not verified
that SwiftUI redraws the row once
messageschanges is not asserted here, and the user-visibleproof of the original report (feat(messages): apply refreshed link metadata to posted row #103's link-preview backfill) needs that PR plus a real OpenGraph
fetch.
at the data level, not measured in a running list.
Message's==staying identity-based — checked by grepacross the app and tests, not by type-checking an alternative.
🤖 Generated with Claude Code