fix: eliminate auto-scroll jitter by deferring mark-as-read visual updates
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 (confirmed by instrumentation):
Every markRead during auto-scroll mutated entries[idx].status,
causing SwiftUI to re-render the row (bold→regular, dot removed).
This changed card height, causing contentSize jumps of 10-128pt
per entry — visible as jitter.

FIX: During auto-scroll, collect entry IDs in deferredReadIDs
instead of mutating entries array. When auto-scroll stops,
flushDeferredReads() applies all status changes at once and
sends a single batched API call.

Manual scroll still marks immediately (no deferral needed since
the user controls the scroll position).

Removed all debug instrumentation.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
Yusuf Suleman
2026-04-04 08:27:37 -05:00
parent 39b9303918
commit 17d10ec4c1
3 changed files with 45 additions and 39 deletions

View File

@@ -94,19 +94,15 @@ final class ReaderViewModel {
func loadMore() async { func loadMore() async {
guard !isLoadingMore, hasMore else { return } guard !isLoadingMore, hasMore else { return }
isLoadingMore = true isLoadingMore = true
print("[SCROLL-DBG] 📥 loadMore START offset=\(offset)")
do { do {
let list = try await fetchEntries(offset: offset) let list = try await fetchEntries(offset: offset)
let count = list.entries.count
entries.append(contentsOf: list.entries) entries.append(contentsOf: list.entries)
total = list.total total = list.total
offset += count offset += list.entries.count
hasMore = offset < list.total hasMore = offset < list.total
print("[SCROLL-DBG] 📥 loadMore END appended=\(count) total=\(entries.count) hasMore=\(hasMore)")
} catch { } catch {
self.error = error.localizedDescription self.error = error.localizedDescription
print("[SCROLL-DBG] 📥 loadMore FAILED: \(error)")
} }
isLoadingMore = false isLoadingMore = false
} }

View File

@@ -13,6 +13,7 @@ struct EntryListView: View {
@State private var cumulativeDown: CGFloat = 0 @State private var cumulativeDown: CGFloat = 0
@State private var cumulativeUp: CGFloat = 0 @State private var cumulativeUp: CGFloat = 0
@State private var markedByScroll: Set<Int> = [] @State private var markedByScroll: Set<Int> = []
@State private var deferredReadIDs: Set<Int> = [] // IDs to mark read when auto-scroll stops
// Viewport height use the first connected scene's screen. // Viewport height use the first connected scene's screen.
private var viewportHeight: CGFloat { private var viewportHeight: CGFloat {
@@ -59,6 +60,11 @@ struct EntryListView: View {
// Stop auto-scroll on navigation return // Stop auto-scroll on navigation return
isAutoScrolling = false isAutoScrolling = false
} }
.onChange(of: isAutoScrolling) { _, scrolling in
if !scrolling {
flushDeferredReads()
}
}
.refreshable { .refreshable {
await vm.refresh() await vm.refresh()
} }
@@ -112,22 +118,49 @@ struct EntryListView: View {
frame.maxY < 30 else { return } frame.maxY < 30 else { return }
markedByScroll.insert(entryId) markedByScroll.insert(entryId)
print("[SCROLL-DBG] 📖 markRead id=\(entryId) entriesCount=\(vm.entries.count)")
if let idx = vm.entries.firstIndex(where: { $0.id == entryId }) { if isAutoScrolling {
vm.entries[idx].status = "read" // Defer visual update contentSize changes cause jitter
} // during auto-scroll. Collect IDs, apply when scroll stops.
deferredReadIDs.insert(entryId)
Task { } else {
let api = ReaderAPI() // Manual scroll apply immediately
try? await api.markEntries(ids: [entryId], status: "read") if let idx = vm.entries.firstIndex(where: { $0.id == entryId }) {
vm.counters = try? await api.getCounters() vm.entries[idx].status = "read"
}
Task {
let api = ReaderAPI()
try? await api.markEntries(ids: [entryId], status: "read")
vm.counters = try? await api.getCounters()
}
} }
} }
} }
) )
} }
// MARK: - Flush Deferred Reads
private func flushDeferredReads() {
guard !deferredReadIDs.isEmpty else { return }
let ids = Array(deferredReadIDs)
deferredReadIDs.removeAll()
// Apply all visual updates at once
for id in ids {
if let idx = vm.entries.firstIndex(where: { $0.id == id }) {
vm.entries[idx].status = "read"
}
}
// Single batched API call
Task {
let api = ReaderAPI()
try? await api.markEntries(ids: ids, status: "read")
vm.counters = try? await api.getCounters()
}
}
// MARK: - Card Layout // MARK: - Card Layout
private var cardLayout: some View { private var cardLayout: some View {

View File

@@ -94,43 +94,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 frameDuration = link.targetTimestamp - link.timestamp let delta = CGFloat(speed) * 60.0 * CGFloat(link.targetTimestamp - link.timestamp)
let expectedDelta = CGFloat(speed) * 60.0 * CGFloat(frameDuration) let newY = min(sv.contentOffset.y + delta, maxOffset)
let beforeY = sv.contentOffset.y
let newY = min(beforeY + expectedDelta, maxOffset)
sv.contentOffset.y = newY sv.contentOffset.y = newY
let actualDelta = sv.contentOffset.y - beforeY
// Detect jitter: contentSize changed, or actual delta differs from expected
let contentSizeChanged = sv.contentSize.height != lastContentSize
let wallDelta = lastTickTime > 0 ? (now - lastTickTime) * 1000 : 0
let isJitter = contentSizeChanged || wallDelta > 25 // >25ms between frames = dropped frame
tickCount += 1
if isJitter || tickCount % 120 == 0 {
// Log on jitter or every 2 seconds
print("[SCROLL-DBG] \(isJitter ? "⚠️ JITTER" : "✅ ok") wallΔ=\(String(format:"%.1f", wallDelta))ms frameDur=\(String(format:"%.1f", frameDuration*1000))ms expΔ=\(String(format:"%.1f", expectedDelta))pt actΔ=\(String(format:"%.1f", actualDelta))pt y=\(Int(sv.contentOffset.y)) contentH=\(Int(sv.contentSize.height)) csChanged=\(contentSizeChanged) speed=\(String(format:"%.2f", speed))")
}
lastTickTime = now
lastContentSize = sv.contentSize.height
originalDelegate?.scrollViewDidScroll?(sv) originalDelegate?.scrollViewDidScroll?(sv)
if newY >= maxOffset - 1 { if newY >= maxOffset - 1 {