From bf04d2b78c498cd4dc1fb87c8f017ce6aa8a1450 Mon Sep 17 00:00:00 2001 From: Adron Hall Date: Wed, 16 Sep 2026 13:48:18 -0700 Subject: [PATCH] fix(feed): reconcile in-place row updates in the feed MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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) Claude-Session: https://claude.ai/code/session_017bss5MgZa7Jvj2m9zdaUd1 --- InterlinedList.xcodeproj/project.pbxproj | 8 + InterlinedList/Services/AppDataStore.swift | 20 ++- InterlinedList/Services/FeedMerge.swift | 37 +++++ InterlinedList/Views/FeedView.swift | 15 +- .../ServiceTests/AppDataStoreTests.swift | 90 ++++++++++- .../ServiceTests/FeedMergeTests.swift | 143 ++++++++++++++++++ 6 files changed, 303 insertions(+), 10 deletions(-) create mode 100644 InterlinedList/Services/FeedMerge.swift create mode 100644 InterlinedListTests/ServiceTests/FeedMergeTests.swift diff --git a/InterlinedList.xcodeproj/project.pbxproj b/InterlinedList.xcodeproj/project.pbxproj index 6883ba6..416b13e 100644 --- a/InterlinedList.xcodeproj/project.pbxproj +++ b/InterlinedList.xcodeproj/project.pbxproj @@ -120,6 +120,8 @@ A5C47F6A46E3CE8708789CB2 /* PublicBrowse.swift in Sources */ = {isa = PBXBuildFile; fileRef = 74B5AC4D76899CEAF620E402 /* PublicBrowse.swift */; }; ADE33267E8D212F2863B1ABB /* APIClientSearchUsersTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 93B62D89758287A728A56C9B /* APIClientSearchUsersTests.swift */; }; B1C1D1E1F1A10001 /* DataCache.swift in Sources */ = {isa = PBXBuildFile; fileRef = B1C1D1E1F1A10002 /* DataCache.swift */; }; + FEED105A0000000000000001 /* FeedMerge.swift in Sources */ = {isa = PBXBuildFile; fileRef = FEED105A0000000000000002 /* FeedMerge.swift */; }; + FEED105A0000000000000003 /* FeedMergeTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = FEED105A0000000000000004 /* FeedMergeTests.swift */; }; B1C1D1E1F1A10003 /* AppDataStore.swift in Sources */ = {isa = PBXBuildFile; fileRef = B1C1D1E1F1A10004 /* AppDataStore.swift */; }; 0A87E63E7459CCDD35BFCFC9 /* MuteConfirmation.swift in Sources */ = {isa = PBXBuildFile; fileRef = 137B3E2137C83882B8434673 /* MuteConfirmation.swift */; }; 1F2D51E9719CC8F468BF5107 /* MuteStoreTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = BA27DCE269D834AE6F1C569D /* MuteStoreTests.swift */; }; @@ -377,6 +379,8 @@ A79A316417B99AB22D2FFDC1 /* JetBrainsMono.ttf */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = file; path = InterlinedList/Fonts/JetBrainsMono.ttf; sourceTree = SOURCE_ROOT; }; AE452BC01A7461AF5199FFE6 /* AIJSON.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = AIJSON.swift; sourceTree = ""; }; B1C1D1E1F1A10002 /* DataCache.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = DataCache.swift; sourceTree = ""; }; + FEED105A0000000000000002 /* FeedMerge.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = FeedMerge.swift; sourceTree = ""; }; + FEED105A0000000000000004 /* FeedMergeTests.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = FeedMergeTests.swift; sourceTree = ""; }; B1C1D1E1F1A10004 /* AppDataStore.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AppDataStore.swift; sourceTree = ""; }; B1C1D1E1F1A10006 /* SkeletonBlock.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SkeletonBlock.swift; sourceTree = ""; }; B1C1D1E1F1A10008 /* FeedSkeletonView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = FeedSkeletonView.swift; sourceTree = ""; }; @@ -592,6 +596,7 @@ 966A4B3A93B371DF62CDE9B4 /* APIClient+ListContributors.swift */, A3C3D3E3F3A30056 /* URLSessionProtocol.swift */, B1C1D1E1F1A10002 /* DataCache.swift */, + FEED105A0000000000000002 /* FeedMerge.swift */, B1C1D1E1F1A10004 /* AppDataStore.swift */, D0A1D0A1D0A10002 /* OAuthCoordinator.swift */, 1F84433332486544DA15C7BA /* APIClient+Documents.swift */, @@ -806,6 +811,7 @@ D4C3953B4568AA7A2EA19EED /* ComposeImageUploaderTests.swift */, BA27DCE269D834AE6F1C569D /* MuteStoreTests.swift */, 8E384ED04860E87973898F89 /* DocumentSyncMergeTests.swift */, + FEED105A0000000000000004 /* FeedMergeTests.swift */, D42A88BDF9EB795F41CF2F64 /* DocumentSyncOutboxTests.swift */, 126B48E798CB7064F46382CC /* DocumentSyncConflictTests.swift */, D2113D42FDEA77E3C0E4974E /* ServerLimitsStoreTests.swift */, @@ -949,6 +955,7 @@ A3C3D3E3F3A30053 /* DocumentsView.swift in Sources */, A3C3D3E3F3A30055 /* URLSessionProtocol.swift in Sources */, B1C1D1E1F1A10001 /* DataCache.swift in Sources */, + FEED105A0000000000000001 /* FeedMerge.swift in Sources */, B1C1D1E1F1A10003 /* AppDataStore.swift in Sources */, B1C1D1E1F1A10005 /* SkeletonBlock.swift in Sources */, B1C1D1E1F1A10007 /* FeedSkeletonView.swift in Sources */, @@ -1119,6 +1126,7 @@ C8AFF749A154E5F96FB77DE2 /* APIClientSessionsTests.swift in Sources */, FC004747AE60A6796DAD36AB /* APIClientDocumentSyncTests.swift in Sources */, 4A2F1A2E2FB0B4BC857F9E30 /* DocumentSyncMergeTests.swift in Sources */, + FEED105A0000000000000003 /* FeedMergeTests.swift in Sources */, 1C47F69DF0848EF0C45C51EE /* DocumentSyncModelTests.swift in Sources */, 213A17C03007A464190DB5D6 /* APIClientMessageByIdTests.swift in Sources */, C6197F8F1B52DAE1B1D4A2F1 /* AppDeepLinkParseTests.swift in Sources */, diff --git a/InterlinedList/Services/AppDataStore.swift b/InterlinedList/Services/AppDataStore.swift index fc0bdf2..22578ba 100644 --- a/InterlinedList/Services/AppDataStore.swift +++ b/InterlinedList/Services/AppDataStore.swift @@ -7,7 +7,16 @@ import Foundation @MainActor final class AppDataStore: ObservableObject { - @Published private(set) var feedMessages: [Message] = [] + @Published private(set) var feedMessages: [Message] = [] { + didSet { feedRevision &+= 1 } + } + + /// Bumped on every mutation of `feedMessages`, in-place row edits included. + /// `Message`'s `Equatable` is identity-based (`id` only), so neither the array + /// nor its `count` moves when a row's content is replaced — a view mirroring the + /// feed has nothing else to observe. + @Published private(set) var feedRevision = 0 + @Published private(set) var feedLoading = true @Published private(set) var feedError: String? @@ -483,6 +492,15 @@ final class AppDataStore: ObservableObject { saveFeedCache() } + /// Replaces an already-loaded row with a newer copy of itself. Unknown id: no-op — + /// the feed page is a window, not the whole timeline, so a message that has scrolled + /// out of it is not an error. + func updateFeedMessage(_ message: Message) { + guard let index = feedMessages.firstIndex(where: { $0.id == message.id }) else { return } + feedMessages[index] = message + saveFeedCache() + } + func removeList(id: String) { userLists.removeAll { $0.id == id }; saveListsCache() } /// Idempotent upsert by id — safe to call after `createDocumentOffline` diff --git a/InterlinedList/Services/FeedMerge.swift b/InterlinedList/Services/FeedMerge.swift new file mode 100644 index 0000000..7dc9d09 --- /dev/null +++ b/InterlinedList/Services/FeedMerge.swift @@ -0,0 +1,37 @@ +// +// FeedMerge.swift +// InterlinedList +// + +import Foundation + +/// Reconciles the store's feed page into a view's working copy of the feed. +/// +/// The working copy is not a plain mirror of `AppDataStore.feedMessages`: pagination +/// appends pages the store never sees, so the store's page has to be merged into it +/// rather than replace it. Rows already held keep their position (and with it the +/// scroll position); a row the store has re-sent replaces the held copy in place, so +/// an edit to an existing row is visible without a reload. +enum FeedMerge { + struct Result { + let messages: [Message] + /// Rows this merge inserted or replaced — the ones whose derived per-row + /// state (dig counts) needs re-seeding. Empty means nothing moved. + let changed: [Message] + } + + static func merge(existing: [Message], incoming: [Message]) -> Result { + let incomingById = Dictionary(incoming.map { ($0.id, $0) }, uniquingKeysWith: { _, last in last }) + var merged = existing + var replaced: [Message] = [] + for index in merged.indices { + guard let updated = incomingById[merged[index].id] else { continue } + merged[index] = updated + replaced.append(updated) + } + let existingIds = Set(existing.map(\.id)) + let inserted = incoming.filter { !existingIds.contains($0.id) } + merged.insert(contentsOf: inserted, at: 0) + return Result(messages: merged, changed: inserted + replaced) + } +} diff --git a/InterlinedList/Views/FeedView.swift b/InterlinedList/Views/FeedView.swift index 292e04e..128aadc 100644 --- a/InterlinedList/Views/FeedView.swift +++ b/InterlinedList/Views/FeedView.swift @@ -318,6 +318,9 @@ struct FeedView: View { if let index = messages.firstIndex(where: { $0.id == updated.id }) { messages[index] = updated } + // The working copy can hold rows the store's page never saw, so the + // local write above stays; this keeps the store and its cache in step. + store.updateFeedMessage(updated) } } .alert("Delete message?", isPresented: Binding( @@ -382,7 +385,7 @@ struct FeedView: View { if authState.isLoggedIn { Task { await loadMessages() } } } return nav - .onChange(of: store.feedMessages.count) { _, _ in + .onChange(of: store.feedRevision) { _, _ in guard !showOnlyMine && tagFilter == nil else { return } if !syncedFromStore { let msgs = store.feedMessages @@ -391,12 +394,10 @@ struct FeedView: View { isLoading = false syncedFromStore = !msgs.isEmpty } else { - let existingIds = Set(messages.map { $0.id }) - let newMessages = store.feedMessages.filter { !existingIds.contains($0.id) } - if !newMessages.isEmpty { - messages.insert(contentsOf: newMessages, at: 0) - initDigStates(from: newMessages) - } + let result = FeedMerge.merge(existing: messages, incoming: store.feedMessages) + guard !result.changed.isEmpty else { return } + messages = result.messages + initDigStates(from: result.changed) } } .onChange(of: store.feedLoading) { _, loading in diff --git a/InterlinedListTests/ServiceTests/AppDataStoreTests.swift b/InterlinedListTests/ServiceTests/AppDataStoreTests.swift index 820f800..305be6c 100644 --- a/InterlinedListTests/ServiceTests/AppDataStoreTests.swift +++ b/InterlinedListTests/ServiceTests/AppDataStoreTests.swift @@ -82,6 +82,92 @@ final class AppDataStoreTests: XCTestCase { wait(for: [countIncreased], timeout: 1.0) } + // MARK: - updateFeedMessage + + func test_updateFeedMessage_replacesTheRowContent() { + sut.insertFeedMessage(makeMessage(id: "a", content: "before")) + sut.updateFeedMessage(makeMessage(id: "a", content: "after")) + XCTAssertEqual(sut.feedMessages.first?.content, "after") + } + + func test_updateFeedMessage_keepsCountAndOrder() { + sut.insertFeedMessage(makeMessage(id: "a")) + sut.insertFeedMessage(makeMessage(id: "b")) + sut.updateFeedMessage(makeMessage(id: "a", content: "edited")) + XCTAssertEqual(sut.feedMessages.map(\.id), ["b", "a"]) + XCTAssertEqual(sut.feedMessages.last?.content, "edited") + } + + func test_updateFeedMessage_leavesOtherRowsAlone() { + sut.insertFeedMessage(makeMessage(id: "a", content: "a-content")) + sut.insertFeedMessage(makeMessage(id: "b", content: "b-content")) + sut.updateFeedMessage(makeMessage(id: "b", content: "edited")) + XCTAssertEqual(sut.feedMessages.last?.content, "a-content") + } + + func test_updateFeedMessage_unknownId_isANoOp() { + sut.insertFeedMessage(makeMessage(id: "a", content: "kept")) + sut.updateFeedMessage(makeMessage(id: "ghost", content: "ignored")) + XCTAssertEqual(sut.feedMessages.map(\.id), ["a"]) + XCTAssertEqual(sut.feedMessages.first?.content, "kept") + } + + // MARK: - feedRevision + + /// The whole point of the revision: `Message` compares by id, so neither the + /// array nor its count moves when a row is edited in place. + func test_feedRevision_movesWhenARowIsEditedInPlace() { + sut.insertFeedMessage(makeMessage(id: "a", content: "before")) + let before = sut.feedRevision + let countBefore = sut.feedMessages.count + sut.updateFeedMessage(makeMessage(id: "a", content: "after")) + XCTAssertEqual(sut.feedMessages.count, countBefore) + XCTAssertGreaterThan(sut.feedRevision, before) + } + + func test_feedRevision_movesWhenARowIsInserted() { + let before = sut.feedRevision + sut.insertFeedMessage(makeMessage(id: "a")) + XCTAssertGreaterThan(sut.feedRevision, before) + } + + func test_feedRevision_doesNotMoveForAnUnknownId() { + sut.insertFeedMessage(makeMessage(id: "a")) + let before = sut.feedRevision + sut.updateFeedMessage(makeMessage(id: "ghost")) + XCTAssertEqual(sut.feedRevision, before) + } + + // MARK: - store mutation reaching the feed's working copy + + /// End of the chain the bug broke: the store edits a row, and the copy `FeedView` + /// renders from picks the edit up through `FeedMerge` without a reload. + func test_storeRowEdit_reachesTheViewsWorkingCopy() { + sut.insertFeedMessage(makeMessage(id: "a", content: "before")) + let viewCopy = sut.feedMessages + let revisionBefore = sut.feedRevision + + sut.updateFeedMessage(makeMessage(id: "a", content: "after")) + + XCTAssertGreaterThan(sut.feedRevision, revisionBefore) + let merged = FeedMerge.merge(existing: viewCopy, incoming: sut.feedMessages) + XCTAssertEqual(merged.messages.count, 1) + XCTAssertEqual(merged.messages.first?.content, "after") + } + + /// The view's copy runs ahead of the store once pagination has appended a page; + /// a store edit must not drop those rows. + func test_storeRowEdit_keepsPaginatedRowsTheStoreNeverSaw() { + sut.insertFeedMessage(makeMessage(id: "p1", content: "before")) + let viewCopy = sut.feedMessages + [makeMessage(id: "p2"), makeMessage(id: "p3")] + + sut.updateFeedMessage(makeMessage(id: "p1", content: "after")) + + let merged = FeedMerge.merge(existing: viewCopy, incoming: sut.feedMessages) + XCTAssertEqual(merged.messages.map(\.id), ["p1", "p2", "p3"]) + XCTAssertEqual(merged.messages.first?.content, "after") + } + // MARK: - reset func test_reset_clearsFeedMessages() { @@ -284,8 +370,8 @@ final class AppDataStoreTests: XCTestCase { // MARK: - Helpers - private func makeMessage(id: String) -> Message { - Message(id: id, content: "test", publiclyVisible: true, + private func makeMessage(id: String, content: String = "test") -> Message { + Message(id: id, content: content, publiclyVisible: true, userId: "u1", createdAt: "2026-01-01T00:00:00Z", updatedAt: nil, user: nil, imageUrls: nil, videoUrls: nil, linkMetadata: nil, parentId: nil, scheduledAt: nil, diff --git a/InterlinedListTests/ServiceTests/FeedMergeTests.swift b/InterlinedListTests/ServiceTests/FeedMergeTests.swift new file mode 100644 index 0000000..2067e34 --- /dev/null +++ b/InterlinedListTests/ServiceTests/FeedMergeTests.swift @@ -0,0 +1,143 @@ +import XCTest +@testable import InterlinedList + +/// `Message`'s `==` is identity-based (id only), so every assertion here compares +/// content fields explicitly — comparing whole `Message` values would pass even when +/// the merge kept a stale row. +final class FeedMergeTests: XCTestCase { + + // MARK: - In-place updates + + func test_merge_existingRowWithNewContent_isReplacedInPlace() { + let existing = [makeMessage(id: "a", content: "before"), makeMessage(id: "b")] + let incoming = [makeMessage(id: "a", content: "after"), makeMessage(id: "b")] + + let result = FeedMerge.merge(existing: existing, incoming: incoming) + + XCTAssertEqual(result.messages.map(\.id), ["a", "b"]) + XCTAssertEqual(result.messages.first?.content, "after") + } + + func test_merge_existingRowWithNewContent_isNotDuplicated() { + let existing = [makeMessage(id: "a", content: "before")] + let incoming = [makeMessage(id: "a", content: "after")] + + let result = FeedMerge.merge(existing: existing, incoming: incoming) + + XCTAssertEqual(result.messages.count, 1) + } + + func test_merge_replacedRow_keepsItsPosition() { + let existing = [makeMessage(id: "a"), makeMessage(id: "b", content: "before"), makeMessage(id: "c")] + let incoming = [makeMessage(id: "b", content: "after")] + + let result = FeedMerge.merge(existing: existing, incoming: incoming) + + XCTAssertEqual(result.messages.map(\.id), ["a", "b", "c"]) + XCTAssertEqual(result.messages[1].content, "after") + } + + func test_merge_linkMetadataBackfill_isVisibleOnTheHeldRow() { + let existing = [makeMessage(id: "a")] + let backfilled = makeMessage( + id: "a", + linkMetadata: LinkMetadata(links: [ + LinkMetadataItem( + url: "https://example.com", + platform: nil, + metadata: LinkMetadataItemContent(thumbnail: nil, title: "Example", + description: nil, text: nil, type: nil), + fetchStatus: nil + ) + ]) + ) + + let result = FeedMerge.merge(existing: existing, incoming: [backfilled]) + + XCTAssertEqual(result.messages.first?.linkMetadata?.links.first?.metadata?.title, "Example") + } + + func test_merge_replacedRow_isReportedAsChanged() { + let result = FeedMerge.merge(existing: [makeMessage(id: "a", content: "before")], + incoming: [makeMessage(id: "a", content: "after")]) + + XCTAssertEqual(result.changed.map(\.id), ["a"]) + XCTAssertEqual(result.changed.first?.content, "after") + } + + // MARK: - Insertions + + func test_merge_unseenId_isPrependedNewestFirst() { + let existing = [makeMessage(id: "old")] + let incoming = [makeMessage(id: "new"), makeMessage(id: "old")] + + let result = FeedMerge.merge(existing: existing, incoming: incoming) + + XCTAssertEqual(result.messages.map(\.id), ["new", "old"]) + } + + func test_merge_severalUnseenIds_keepIncomingOrder() { + let existing = [makeMessage(id: "old")] + let incoming = [makeMessage(id: "n1"), makeMessage(id: "n2"), makeMessage(id: "old")] + + let result = FeedMerge.merge(existing: existing, incoming: incoming) + + XCTAssertEqual(result.messages.map(\.id), ["n1", "n2", "old"]) + } + + func test_merge_insertedRow_isReportedAsChanged() { + let result = FeedMerge.merge(existing: [makeMessage(id: "old")], + incoming: [makeMessage(id: "new")]) + + XCTAssertTrue(result.changed.contains { $0.id == "new" }) + } + + // MARK: - Rows the store does not hold + + func test_merge_paginatedRowsNotInIncoming_areKept() { + // Page 2+ is appended by the view and never reaches the store. + let existing = [makeMessage(id: "p1"), makeMessage(id: "p2"), makeMessage(id: "p3")] + let incoming = [makeMessage(id: "p1")] + + let result = FeedMerge.merge(existing: existing, incoming: incoming) + + XCTAssertEqual(result.messages.map(\.id), ["p1", "p2", "p3"]) + } + + func test_merge_emptyIncoming_changesNothing() { + let existing = [makeMessage(id: "a"), makeMessage(id: "b")] + + let result = FeedMerge.merge(existing: existing, incoming: []) + + XCTAssertEqual(result.messages.map(\.id), ["a", "b"]) + XCTAssertTrue(result.changed.isEmpty) + } + + func test_merge_emptyExisting_takesIncomingAsIs() { + let result = FeedMerge.merge(existing: [], incoming: [makeMessage(id: "a"), makeMessage(id: "b")]) + + XCTAssertEqual(result.messages.map(\.id), ["a", "b"]) + XCTAssertEqual(result.changed.map(\.id), ["a", "b"]) + } + + func test_merge_identicalInput_reportsTheRowsItRefreshed() { + let existing = [makeMessage(id: "a"), makeMessage(id: "b")] + + let result = FeedMerge.merge(existing: existing, incoming: existing) + + XCTAssertEqual(result.messages.map(\.id), ["a", "b"]) + XCTAssertEqual(result.changed.count, 2) + } + + // MARK: - Helpers + + private func makeMessage(id: String, + content: String = "test", + linkMetadata: LinkMetadata? = nil) -> Message { + Message(id: id, content: content, publiclyVisible: true, + userId: "u1", createdAt: "2026-01-01T00:00:00Z", + updatedAt: nil, user: nil, imageUrls: nil, videoUrls: nil, + linkMetadata: linkMetadata, parentId: nil, scheduledAt: nil, + tags: nil, digCount: 0, dugByMe: false, crossPostUrls: nil) + } +}