From 58dd589d5ac6cd68751b5c50aa52d9e0c5414577 Mon Sep 17 00:00:00 2001 From: Yusuf Suleman Date: Sat, 4 Apr 2026 23:20:49 -0500 Subject: [PATCH] =?UTF-8?q?fix:=20Reader=20loadMore=20failing=20=E2=80=94?= =?UTF-8?q?=20duplicate=20IDs=20+=20offset=20drift?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ROOT CAUSE (from instrumented logs): 1. Duplicate entry IDs in LazyVStack — loadMore appended entries that already existed. IDs 37603/37613 appeared twice, causing "undefined results" from SwiftUI. 2. Offset drift — marking entries as read shifted the unread filter results. offset=30 no longer pointed to page 2; entries moved. 3. loadMore returned +0 entries because offset was past the shifted end of the filtered dataset. FIXES: 1. Deduplication: loadMore filters out entries whose IDs already exist in the array before appending. 2. Use entries.count as offset instead of a separate counter. This naturally accounts for deduplication and status changes. 3. hasMore based on whether new (deduped) entries were actually added, not just API page size. Removed all debug logging. Co-Authored-By: Claude Opus 4.6 (1M context) --- .../Reader/ViewModels/ReaderViewModel.swift | 27 +++++++++++-------- .../Features/Reader/Views/ArticleView.swift | 11 +------- .../Reader/Views/ArticleWebView.swift | 2 -- .../Features/Reader/Views/EntryListView.swift | 1 - .../Reader/Views/ScrollViewDriver.swift | 18 +------------ 5 files changed, 18 insertions(+), 41 deletions(-) diff --git a/ios/Platform/Platform/Features/Reader/ViewModels/ReaderViewModel.swift b/ios/Platform/Platform/Features/Reader/ViewModels/ReaderViewModel.swift index ad21dfd..8614c21 100644 --- a/ios/Platform/Platform/Features/Reader/ViewModels/ReaderViewModel.swift +++ b/ios/Platform/Platform/Features/Reader/ViewModels/ReaderViewModel.swift @@ -72,7 +72,6 @@ final class ReaderViewModel { if reset { offset = 0 hasMore = true - // DO NOT set entries = [] — causes full list teardown + empty flash } guard !isLoading else { return } isLoading = true @@ -80,7 +79,7 @@ final class ReaderViewModel { do { let list = try await fetchEntries(offset: 0) - // Atomic swap — SwiftUI diffs by Identifiable.id + // Atomic swap — fresh list replaces old (no duplicates possible) entries = list.entries total = list.total offset = list.entries.count @@ -94,19 +93,25 @@ final class ReaderViewModel { func loadMore() async { guard !isLoadingMore, hasMore else { return } isLoadingMore = true - print("[READER-DBG] 📥 loadMore START offset=\(offset)") do { - let list = try await fetchEntries(offset: offset) - let count = list.entries.count - entries.append(contentsOf: list.entries) - total = list.total - offset += count - hasMore = offset < list.total - print("[READER-DBG] 📥 loadMore END +\(count) total=\(entries.count) hasMore=\(hasMore)") + // Use entries.count as offset — accounts for deduplication and + // avoids offset drift when entries change status during scroll + let list = try await fetchEntries(offset: entries.count) + + if list.entries.isEmpty { + hasMore = false + } else { + // Deduplicate — only append entries with IDs not already in the array + let existingIDs = Set(entries.map(\.id)) + let newEntries = list.entries.filter { !existingIDs.contains($0.id) } + entries.append(contentsOf: newEntries) + total = list.total + offset = entries.count + hasMore = newEntries.count > 0 + } } catch { self.error = error.localizedDescription - print("[READER-DBG] 📥 loadMore ERROR: \(error)") } isLoadingMore = false } diff --git a/ios/Platform/Platform/Features/Reader/Views/ArticleView.swift b/ios/Platform/Platform/Features/Reader/Views/ArticleView.swift index 0459a82..577e6cd 100644 --- a/ios/Platform/Platform/Features/Reader/Views/ArticleView.swift +++ b/ios/Platform/Platform/Features/Reader/Views/ArticleView.swift @@ -59,16 +59,12 @@ struct ArticleView: View { } } .task { - let t0 = CFAbsoluteTimeGetCurrent() let entryId = entry.id - print("[READER-DBG] 📄 article OPEN id=\(entryId) initContent=\(articleContent.count)") - if let idx = vm.entries.firstIndex(where: { $0.id == entryId }), !vm.entries[idx].isRead { vm.entries[idx].status = "read" } currentEntry = vm.entries.first(where: { $0.id == entryId }) ?? currentEntry - print("[READER-DBG] 📄 markRead done +\(Int((CFAbsoluteTimeGetCurrent()-t0)*1000))ms") Task { let api = ReaderAPI() @@ -78,12 +74,10 @@ struct ArticleView: View { do { let fullEntry = try await ReaderAPI().getEntry(id: entryId) - print("[READER-DBG] 📄 getEntry +\(Int((CFAbsoluteTimeGetCurrent()-t0)*1000))ms contentLen=\(fullEntry.articleHTML.count)") let fullHTML = fullEntry.articleHTML if !fullHTML.isEmpty && fullHTML.count > articleContent.count { articleContent = fullHTML - print("[READER-DBG] 📄 content upgraded +\(Int((CFAbsoluteTimeGetCurrent()-t0)*1000))ms") } var merged = fullEntry @@ -92,10 +86,7 @@ struct ArticleView: View { merged.starred = local.starred } currentEntry = merged - print("[READER-DBG] 📄 article READY +\(Int((CFAbsoluteTimeGetCurrent()-t0)*1000))ms") - } catch { - print("[READER-DBG] 📄 article FAILED +\(Int((CFAbsoluteTimeGetCurrent()-t0)*1000))ms error=\(error)") - } + } catch {} } } diff --git a/ios/Platform/Platform/Features/Reader/Views/ArticleWebView.swift b/ios/Platform/Platform/Features/Reader/Views/ArticleWebView.swift index a619b6e..bc45a5f 100644 --- a/ios/Platform/Platform/Features/Reader/Views/ArticleWebView.swift +++ b/ios/Platform/Platform/Features/Reader/Views/ArticleWebView.swift @@ -100,8 +100,6 @@ struct ArticleWebView: UIViewRepresentable { // Only reload if content meaningfully changed guard context.coordinator.lastHTML != newHTML else { return } - print("[READER-DBG] 🌐 webView load htmlLen=\(newHTML.count) isUpgrade=\(context.coordinator.lastHTML != nil)") - let isUpgrade = context.coordinator.lastHTML != nil context.coordinator.lastHTML = newHTML diff --git a/ios/Platform/Platform/Features/Reader/Views/EntryListView.swift b/ios/Platform/Platform/Features/Reader/Views/EntryListView.swift index 7a9c180..cba81bf 100644 --- a/ios/Platform/Platform/Features/Reader/Views/EntryListView.swift +++ b/ios/Platform/Platform/Features/Reader/Views/EntryListView.swift @@ -123,7 +123,6 @@ struct EntryListView: View { frame.maxY < 30 else { return } markedByScroll.insert(entryId) - print("[READER-DBG] 📖 markRead id=\(entryId) autoScroll=\(isAutoScrolling) maxY=\(Int(frame.maxY))") if isAutoScrolling { // Defer visual update — contentSize changes cause jitter diff --git a/ios/Platform/Platform/Features/Reader/Views/ScrollViewDriver.swift b/ios/Platform/Platform/Features/Reader/Views/ScrollViewDriver.swift index 9507683..88cf223 100644 --- a/ios/Platform/Platform/Features/Reader/Views/ScrollViewDriver.swift +++ b/ios/Platform/Platform/Features/Reader/Views/ScrollViewDriver.swift @@ -98,36 +98,20 @@ struct ScrollViewDriver: UIViewRepresentable { displayLink = nil } - private var lastTickTime: CFAbsoluteTime = 0 - private var lastContentSize: CGFloat = 0 - private var tickCount = 0 - @objc private func tick(_ link: CADisplayLink) { guard let sv = scrollView else { stopAndNotify() return } - let now = CFAbsoluteTimeGetCurrent() let maxOffset = sv.contentSize.height - sv.bounds.height + sv.contentInset.bottom guard maxOffset > 0 else { return } let delta = CGFloat(speed) * 60.0 * CGFloat(link.targetTimestamp - link.timestamp) - let beforeY = sv.contentOffset.y - let newY = min(beforeY + delta, maxOffset) + let newY = min(sv.contentOffset.y + delta, maxOffset) sv.contentOffset.y = newY - // Jitter detection - let wallDelta = lastTickTime > 0 ? (now - lastTickTime) * 1000 : 0 - let csChanged = sv.contentSize.height != lastContentSize - tickCount += 1 - if csChanged || wallDelta > 25 || tickCount % 180 == 0 { - print("[READER-DBG] \(csChanged || wallDelta > 25 ? "⚠️" : "✅") wallΔ=\(String(format:"%.1f",wallDelta))ms y=\(Int(newY)) csH=\(Int(sv.contentSize.height)) csΔ=\(Int(sv.contentSize.height - lastContentSize)) speed=\(String(format:"%.2f",speed)) dist=\(Int(maxOffset - newY))") - } - lastTickTime = now - lastContentSize = sv.contentSize.height - originalDelegate?.scrollViewDidScroll?(sv) // Trigger load more when within 500pt of bottom