fix: Reader loadMore failing — duplicate IDs + offset drift
All checks were successful
Security Checks / dependency-audit (push) Successful in 13s
Security Checks / secret-scanning (push) Successful in 4s
Security Checks / dockerfile-lint (push) Successful in 4s

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) <noreply@anthropic.com>
This commit is contained in:
Yusuf Suleman
2026-04-04 23:20:49 -05:00
parent 4461689251
commit 58dd589d5a
5 changed files with 18 additions and 41 deletions

View File

@@ -72,7 +72,6 @@ final class ReaderViewModel {
if reset { if reset {
offset = 0 offset = 0
hasMore = true hasMore = true
// DO NOT set entries = [] causes full list teardown + empty flash
} }
guard !isLoading else { return } guard !isLoading else { return }
isLoading = true isLoading = true
@@ -80,7 +79,7 @@ final class ReaderViewModel {
do { do {
let list = try await fetchEntries(offset: 0) 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 entries = list.entries
total = list.total total = list.total
offset = list.entries.count offset = list.entries.count
@@ -94,19 +93,25 @@ final class ReaderViewModel {
func loadMore() async { func loadMore() async {
guard !isLoadingMore, hasMore else { return } guard !isLoadingMore, hasMore else { return }
isLoadingMore = true isLoadingMore = true
print("[READER-DBG] 📥 loadMore START offset=\(offset)")
do { do {
let list = try await fetchEntries(offset: offset) // Use entries.count as offset accounts for deduplication and
let count = list.entries.count // avoids offset drift when entries change status during scroll
entries.append(contentsOf: list.entries) 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 total = list.total
offset += count offset = entries.count
hasMore = offset < list.total hasMore = newEntries.count > 0
print("[READER-DBG] 📥 loadMore END +\(count) total=\(entries.count) hasMore=\(hasMore)") }
} catch { } catch {
self.error = error.localizedDescription self.error = error.localizedDescription
print("[READER-DBG] 📥 loadMore ERROR: \(error)")
} }
isLoadingMore = false isLoadingMore = false
} }

View File

@@ -59,16 +59,12 @@ struct ArticleView: View {
} }
} }
.task { .task {
let t0 = CFAbsoluteTimeGetCurrent()
let entryId = entry.id let entryId = entry.id
print("[READER-DBG] 📄 article OPEN id=\(entryId) initContent=\(articleContent.count)")
if let idx = vm.entries.firstIndex(where: { $0.id == entryId }), if let idx = vm.entries.firstIndex(where: { $0.id == entryId }),
!vm.entries[idx].isRead { !vm.entries[idx].isRead {
vm.entries[idx].status = "read" vm.entries[idx].status = "read"
} }
currentEntry = vm.entries.first(where: { $0.id == entryId }) ?? currentEntry currentEntry = vm.entries.first(where: { $0.id == entryId }) ?? currentEntry
print("[READER-DBG] 📄 markRead done +\(Int((CFAbsoluteTimeGetCurrent()-t0)*1000))ms")
Task { Task {
let api = ReaderAPI() let api = ReaderAPI()
@@ -78,12 +74,10 @@ struct ArticleView: View {
do { do {
let fullEntry = try await ReaderAPI().getEntry(id: entryId) 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 let fullHTML = fullEntry.articleHTML
if !fullHTML.isEmpty && fullHTML.count > articleContent.count { if !fullHTML.isEmpty && fullHTML.count > articleContent.count {
articleContent = fullHTML articleContent = fullHTML
print("[READER-DBG] 📄 content upgraded +\(Int((CFAbsoluteTimeGetCurrent()-t0)*1000))ms")
} }
var merged = fullEntry var merged = fullEntry
@@ -92,10 +86,7 @@ struct ArticleView: View {
merged.starred = local.starred merged.starred = local.starred
} }
currentEntry = merged currentEntry = merged
print("[READER-DBG] 📄 article READY +\(Int((CFAbsoluteTimeGetCurrent()-t0)*1000))ms") } catch {}
} catch {
print("[READER-DBG] 📄 article FAILED +\(Int((CFAbsoluteTimeGetCurrent()-t0)*1000))ms error=\(error)")
}
} }
} }

View File

@@ -100,8 +100,6 @@ struct ArticleWebView: UIViewRepresentable {
// Only reload if content meaningfully changed // Only reload if content meaningfully changed
guard context.coordinator.lastHTML != newHTML else { return } 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 let isUpgrade = context.coordinator.lastHTML != nil
context.coordinator.lastHTML = newHTML context.coordinator.lastHTML = newHTML

View File

@@ -123,7 +123,6 @@ struct EntryListView: View {
frame.maxY < 30 else { return } frame.maxY < 30 else { return }
markedByScroll.insert(entryId) markedByScroll.insert(entryId)
print("[READER-DBG] 📖 markRead id=\(entryId) autoScroll=\(isAutoScrolling) maxY=\(Int(frame.maxY))")
if isAutoScrolling { if isAutoScrolling {
// Defer visual update contentSize changes cause jitter // Defer visual update contentSize changes cause jitter

View File

@@ -98,36 +98,20 @@ struct ScrollViewDriver: UIViewRepresentable {
displayLink = nil displayLink = nil
} }
private var lastTickTime: CFAbsoluteTime = 0
private var lastContentSize: CGFloat = 0
private var tickCount = 0
@objc private func tick(_ link: CADisplayLink) { @objc private func tick(_ link: CADisplayLink) {
guard let sv = scrollView else { guard let sv = scrollView else {
stopAndNotify() stopAndNotify()
return return
} }
let now = CFAbsoluteTimeGetCurrent()
let maxOffset = sv.contentSize.height - sv.bounds.height + sv.contentInset.bottom let maxOffset = sv.contentSize.height - sv.bounds.height + sv.contentInset.bottom
guard maxOffset > 0 else { return } guard maxOffset > 0 else { return }
let delta = CGFloat(speed) * 60.0 * CGFloat(link.targetTimestamp - link.timestamp) let delta = CGFloat(speed) * 60.0 * CGFloat(link.targetTimestamp - link.timestamp)
let beforeY = sv.contentOffset.y let newY = min(sv.contentOffset.y + delta, maxOffset)
let newY = min(beforeY + delta, maxOffset)
sv.contentOffset.y = newY 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) originalDelegate?.scrollViewDidScroll?(sv)
// Trigger load more when within 500pt of bottom // Trigger load more when within 500pt of bottom