keep state of aritcle/list view
This commit is contained in:
@@ -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: {
|
||||||
|
|||||||
@@ -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
|
||||||
observedDivs.forEach(observedDiv => {
|
// an initial snapshot to drop (a connect while scrolled down would otherwise
|
||||||
observer.observe(observedDiv);
|
// 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) {
|
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()
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user