keep state of aritcle/list view

This commit is contained in:
2026-07-12 17:22:30 +02:00
parent 31991ea1f8
commit 2b1d1c57ff
2 changed files with 62 additions and 7 deletions
+34 -1
View File
@@ -13,7 +13,7 @@ class FakeIntersectionObserver {
vi.stubGlobal('IntersectionObserver', FakeIntersectionObserver) vi.stubGlobal('IntersectionObserver', FakeIntersectionObserver)
describe('useFeeds', () => { 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(() => { beforeEach(() => {
localStorage.setItem('user-token', 'test-token') localStorage.setItem('user-token', 'test-token')
@@ -119,6 +119,39 @@ describe('useFeeds', () => {
setInitialLoad(false) 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', () => { describe('feed filter', () => {
const twoFeedsResponse = { const twoFeedsResponse = {
data: { data: {
+25 -3
View File
@@ -34,12 +34,21 @@ const unreadCount = computed(() => {
}) })
const message = ref('') const message = ref('')
const showModal = ref(false) 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 currentIndex = ref(0)
const layout = ref(localStorage.getItem('layout') || 'list') // 'list' | 'cards' — list-view display style, toggled from the hamburger menu 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 observer; // Declare observer outside the setup function
let initialLoad = false 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 // Timestamp (performance.now()) of the most recent programmatic scroll / list
// mutation that moves the page without user intent — currently the list-view // mutation that moves the page without user intent — currently the list-view
@@ -282,14 +291,25 @@ function setupIntersectionObserver() {
}); });
const observedDivs = document.querySelectorAll(".observe"); const observedDivs = document.querySelectorAll(".observe");
if (observedDivs.length > 0) { // 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 => { observedDivs.forEach(observedDiv => {
observer.observe(observedDiv); observer.observe(observedDiv);
}) })
} }
}
function handleIntersection(entries, topbarHeight = 0) { 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 // Resolve all affected feeds before touching feeds.value — the target.id
// indices are render-time positions that shift once we splice the array. // indices are render-time positions that shift once we splice the array.
const readFeeds = entries const readFeeds = entries
@@ -386,6 +406,7 @@ async function leaveArticleView() {
allItems.value = allItems.value.filter(feed => !feed.read) allItems.value = allItems.value.filter(feed => !feed.read)
currentIndex.value = 0 currentIndex.value = 0
viewMode.value = 'list' viewMode.value = 'list'
localStorage.setItem('viewMode', viewMode.value)
// The v-if on the list container tears down and recreates all .observe DOM // The v-if on the list container tears down and recreates all .observe DOM
// nodes when switching views, so the intersection observer must be // nodes when switching views, so the intersection observer must be
// re-pointed at the new elements after Vue has finished rendering. // re-pointed at the new elements after Vue has finished rendering.
@@ -405,6 +426,7 @@ function toggleViewMode() {
observer = null observer = null
} }
viewMode.value = 'article' viewMode.value = 'article'
localStorage.setItem('viewMode', viewMode.value)
currentIndex.value = 0 currentIndex.value = 0
markCurrentArticleRead() markCurrentArticleRead()
} }