From 2b1d1c57ffa73c7b01bcd347dd6996e052006aa6 Mon Sep 17 00:00:00 2001 From: mace Date: Sun, 12 Jul 2026 17:22:30 +0200 Subject: [PATCH] keep state of aritcle/list view --- .../composables/__tests__/useFeeds.spec.js | 35 ++++++++++++++++++- vue/src/composables/useFeeds.js | 34 ++++++++++++++---- 2 files changed, 62 insertions(+), 7 deletions(-) diff --git a/vue/src/composables/__tests__/useFeeds.spec.js b/vue/src/composables/__tests__/useFeeds.spec.js index 5c661ce..2cf58b0 100644 --- a/vue/src/composables/__tests__/useFeeds.spec.js +++ b/vue/src/composables/__tests__/useFeeds.spec.js @@ -13,7 +13,7 @@ class FakeIntersectionObserver { vi.stubGlobal('IntersectionObserver', FakeIntersectionObserver) describe('useFeeds', () => { - const { feeds, allItems, feedFilter, feedTitles, unreadCount, setFeedFilter, showMessage, message, showModal, fetchData, sync, getReadable, setInitialLoad, handleIntersection, markAllRead } = useFeeds() + const { feeds, allItems, feedFilter, feedTitles, unreadCount, setFeedFilter, showMessage, message, showModal, fetchData, sync, getReadable, setInitialLoad, handleIntersection, markAllRead, setupIntersectionObserver } = useFeeds() beforeEach(() => { localStorage.setItem('user-token', 'test-token') @@ -119,6 +119,39 @@ describe('useFeeds', () => { setInitialLoad(false) }) + it('ignores the initial observer snapshot on connect so a scrolled-down reconnect cannot mass-mark', async () => { + feeds.value = [ + { id: 201, title: 'First' }, + { id: 202, title: 'Second' }, + ] + setInitialLoad(true) + axios.put.mockResolvedValue({ status: 200 }) + + // A .observe node must exist for setupIntersectionObserver to arm the skip. + const node = document.createElement('div') + node.className = 'observe' + document.body.appendChild(node) + setupIntersectionObserver() + + // First callback after connect is the initial snapshot — must be dropped + // even though both entries look "scrolled past" (above the topbar). + await handleIntersection([ + { isIntersecting: false, boundingClientRect: { y: -10 }, target: { id: '0' } }, + { isIntersecting: false, boundingClientRect: { y: -5 }, target: { id: '1' } }, + ]) + expect(axios.put).not.toHaveBeenCalled() + expect(feeds.value).toHaveLength(2) + + // A genuine later scroll-past still marks read. + await handleIntersection([ + { isIntersecting: false, boundingClientRect: { y: -10 }, target: { id: '0' } }, + ]) + expect(axios.put).toHaveBeenCalledWith('/api/v1/article/read/201', null, expect.anything()) + + document.body.removeChild(node) + setInitialLoad(false) + }) + describe('feed filter', () => { const twoFeedsResponse = { data: { diff --git a/vue/src/composables/useFeeds.js b/vue/src/composables/useFeeds.js index 498f1b2..cf33af3 100644 --- a/vue/src/composables/useFeeds.js +++ b/vue/src/composables/useFeeds.js @@ -34,12 +34,21 @@ const unreadCount = computed(() => { }) const message = ref('') const showModal = ref(false) -const viewMode = ref('list') // 'list' | 'article' — toggled from the hamburger menu +const viewMode = ref(localStorage.getItem('viewMode') || 'list') // 'list' | 'article' — toggled from the hamburger menu, persisted per device const currentIndex = ref(0) const layout = ref(localStorage.getItem('layout') || 'list') // 'list' | 'cards' — list-view display style, toggled from the hamburger menu let observer; // Declare observer outside the setup function let initialLoad = false +// An IntersectionObserver always delivers an initial snapshot for every element +// it starts observing. If the observer (re)connects while the page is scrolled +// down — e.g. leaving a scrolled article view, an HMR module reload, or the +// post-splice re-setup in handleIntersection — that snapshot reports every +// article above the viewport as "not intersecting / scrolled past" and would +// mark them all read at once. So each setupIntersectionObserver() sets this and +// handleIntersection() drops exactly the first callback after a connect; only +// genuine scroll-driven exits mark read after that. +let skipNextObservation = false // Timestamp (performance.now()) of the most recent programmatic scroll / list // mutation that moves the page without user intent — currently the list-view @@ -282,14 +291,25 @@ function setupIntersectionObserver() { }); const observedDivs = document.querySelectorAll(".observe"); - if (observedDivs.length > 0) { - observedDivs.forEach(observedDiv => { - observer.observe(observedDiv); - }) - } + // Arm the skip only when we actually observe nodes: an observe() batch fires + // an initial snapshot to drop (a connect while scrolled down would otherwise + // mass-mark everything above the viewport), but with nothing observed there's + // no snapshot — and assigning unconditionally clears any stale flag from a + // prior no-op setup (e.g. one run in article view, which has no .observe nodes). + skipNextObservation = observedDivs.length > 0 + observedDivs.forEach(observedDiv => { + observer.observe(observedDiv); + }) } function handleIntersection(entries, topbarHeight = 0) { + // Drop the initial snapshot fired on (re)connect — see skipNextObservation. + // The old observer is disconnected before this new one observes, so only the + // latest observer's initial callback reaches here; one skip is enough. + if (skipNextObservation) { + skipNextObservation = false + return + } // 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 @@ -386,6 +406,7 @@ async function leaveArticleView() { allItems.value = allItems.value.filter(feed => !feed.read) currentIndex.value = 0 viewMode.value = 'list' + localStorage.setItem('viewMode', viewMode.value) // The v-if on the list container tears down and recreates all .observe DOM // nodes when switching views, so the intersection observer must be // re-pointed at the new elements after Vue has finished rendering. @@ -405,6 +426,7 @@ function toggleViewMode() { observer = null } viewMode.value = 'article' + localStorage.setItem('viewMode', viewMode.value) currentIndex.value = 0 markCurrentArticleRead() }