fix: scroll mark-as-read crossing event missed by LazyVStack recycling
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 3s

ROOT CAUSE: The exact crossing condition (oldMaxY >= 0 && newMaxY < 0)
required onChange to fire at the exact moment maxY crosses zero. But
LazyVStack recycles views when they scroll off-screen, destroying the
GeometryReader before the crossing event is delivered. The entry goes
from maxY=15 to being recycled — onChange never sees maxY go negative.

FIX: Replace exact crossing with position check (newMaxY < 0). The
entry just needs to be fully above the viewport. The other 5 guards
prevent false positives:
  1. trackingActive (scrolled past threshold)
  2. isScrollingDown
  3. !entry.isRead
  4. !markedByScroll (dedup)
  5. wasVisible (was >=50% visible)

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
Yusuf Suleman
2026-04-03 23:58:31 -05:00
parent 99ad307103
commit cd64255632

View File

@@ -131,21 +131,24 @@ struct EntryListView: View {
wasVisible.insert(entry.id) wasVisible.insert(entry.id)
} }
} }
.onChange(of: frame.maxY) { oldMaxY, newMaxY in .onChange(of: frame.maxY) { _, newMaxY in
// Mark as read when entry scrolls above viewport. // Mark as read when entry is above viewport.
// All 6 conditions must be true: // 5 conditions must be true:
// 1. Tracking active (scrolled past dynamic threshold) // 1. Tracking active (scrolled past dynamic threshold)
// 2. Scrolling downward // 2. Scrolling downward
// 3. Entry is unread // 3. Entry is unread
// 4. Not already marked by scroll // 4. Not already marked by scroll
// 5. Was >=50% visible at some point (genuinely seen) // 5. Was >=50% visible at some point (genuinely seen)
// 6. Bottom edge just crossed above viewport //
// We check newMaxY < 0 (entry fully above viewport)
// instead of requiring an exact crossing event, because
// LazyVStack can recycle the view between onChange calls,
// causing the 0-boundary crossing to never be delivered.
guard trackingActive, guard trackingActive,
isScrollingDown, isScrollingDown,
!entry.isRead, !entry.isRead,
!markedByScroll.contains(entry.id), !markedByScroll.contains(entry.id),
wasVisible.contains(entry.id), wasVisible.contains(entry.id),
oldMaxY >= 0,
newMaxY < 0 else { return } newMaxY < 0 else { return }
markedByScroll.insert(entry.id) markedByScroll.insert(entry.id)