fix: Reader loadMore failing — duplicate IDs + offset drift
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:
@@ -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)
|
||||
// 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 += count
|
||||
hasMore = offset < list.total
|
||||
print("[READER-DBG] 📥 loadMore END +\(count) total=\(entries.count) hasMore=\(hasMore)")
|
||||
offset = entries.count
|
||||
hasMore = newEntries.count > 0
|
||||
}
|
||||
} catch {
|
||||
self.error = error.localizedDescription
|
||||
print("[READER-DBG] 📥 loadMore ERROR: \(error)")
|
||||
}
|
||||
isLoadingMore = false
|
||||
}
|
||||
|
||||
@@ -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 {}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user