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 {
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
}

View File

@@ -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 {}
}
}

View File

@@ -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

View File

@@ -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

View File

@@ -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