From 4d3f5d3285efd920460ba4d3e54d1a5cf6268998 Mon Sep 17 00:00:00 2001 From: mace Date: Tue, 16 Jun 2026 16:26:33 +0200 Subject: [PATCH] fix autoscroll --- vue/src/composables/useFeeds.js | 51 +++++++++++++++++++++------------ 1 file changed, 32 insertions(+), 19 deletions(-) diff --git a/vue/src/composables/useFeeds.js b/vue/src/composables/useFeeds.js index 5825304..7339260 100644 --- a/vue/src/composables/useFeeds.js +++ b/vue/src/composables/useFeeds.js @@ -200,13 +200,9 @@ function setupIntersectionObserver() { } } -async function handleIntersection(entries, topbarHeight = 0) { - // An article that has scrolled past the (possibly sticky-bar-shrunk) top - // edge of the viewport (not intersecting, bounding box above that edge) - // has been read. Resolve all affected feeds up front, before any removal — - // splicing `feeds` while iterating would shift the array indices that later - // entries' `target.id` refer to, causing the wrong item to be marked read - // and removed. +function handleIntersection(entries, topbarHeight = 0) { + // Resolve all affected feeds before touching feeds.value — the target.id + // indices are render-time positions that shift once we splice the array. const readFeeds = entries .filter(entry => initialLoad === true && !entry.isIntersecting && entry.boundingClientRect.y < topbarHeight) .map(entry => feeds.value[entry.target.id]) @@ -214,22 +210,38 @@ async function handleIntersection(entries, topbarHeight = 0) { if (readFeeds.length === 0) return - for (const feed of readFeeds) { - await markRead(feed.id) + // Disconnect before the DOM mutation. In card layout the cards are short + // enough that the shift caused by removing one can push the next card above + // the header, which the observer would immediately treat as another read — + // cascading until many articles disappear at once. + if (observer) { + observer.disconnect() + observer = null } const readIds = new Set(readFeeds.map(feed => feed.id)) feeds.value = feeds.value.filter(feed => !readIds.has(feed.id)) - // Removing .observe nodes that have already scrolled above the viewport - // shrinks the document above the current scroll position — native CSS - // scroll anchoring (on by default, no overflow-anchor override here) - // compensates for that automatically by keeping the visually-anchored - // node in place. Do NOT add a scrollIntoView()/scrollTo() here: an - // earlier version called `document.getElementById(0)?.scrollIntoView()` - // to fix a past scroll-jump complaint, but by the time several articles - // have been read, id "0" is an already-read element far above the - // viewport — forcing a jump to it fights scroll anchoring and is exactly - // the stutter being fixed now. + + for (const feed of readFeeds) { + markRead(feed.id) + } + + nextTick().then(() => { + // If scroll anchoring didn't compensate for the removed content (common + // with position:fixed headers and overflow-x:hidden on body), the first + // remaining article will have drifted above the header. Correct the scroll + // position so it sits exactly at the header bottom before reconnecting — + // otherwise the initial observation would immediately mark everything above + // the topbar as read and cascade until the list is empty. + const first = document.querySelector('.observe') + if (first) { + const top = first.getBoundingClientRect().top + if (top < topbarHeight) { + window.scrollBy(0, top - topbarHeight) + } + } + setupIntersectionObserver() + }) } function setInitialLoad(value) { @@ -295,6 +307,7 @@ async function toggleLayout() { observer.disconnect() observer = null } + window.scrollTo(0, 0) layout.value = layout.value === 'list' ? 'cards' : 'list' localStorage.setItem('layout', layout.value) await nextTick()