Show a sync-in-progress spinner in the header next to the feed filter
activeSyncCount/isSyncing() in useFeeds.js already refcounted both boot reload (fetchData) and manual sync() but wasn't reactive. Make it a ref + computed and expose it so AppNav can render a small spinner next to the feed filter whenever a sync is in flight. The spinner sits outside the filter's own v-if so it also shows during a manual sync from pages other than /feeds, and its box is always reserved (visibility toggle, not v-if) so the one-time --app-nav-height measurement stays correct.
This commit is contained in:
@@ -5,7 +5,7 @@ import { useFeeds, logout as logoutSession } from '@/composables/useFeeds'
|
|||||||
|
|
||||||
const router = useRouter()
|
const router = useRouter()
|
||||||
const route = useRoute()
|
const route = useRoute()
|
||||||
const { sync, viewMode, toggleViewMode, layout, toggleLayout, markAllRead, feedFilter, feedTitles, setFeedFilter, unreadCount, displayedFeedUnreadCounts, refreshUnreadDisplay, lastProgrammaticScroll } = useFeeds()
|
const { sync, viewMode, toggleViewMode, layout, toggleLayout, markAllRead, feedFilter, feedTitles, setFeedFilter, unreadCount, displayedFeedUnreadCounts, refreshUnreadDisplay, lastProgrammaticScroll, isSyncing } = useFeeds()
|
||||||
|
|
||||||
const headerRef = ref(null)
|
const headerRef = ref(null)
|
||||||
|
|
||||||
@@ -161,6 +161,11 @@ function handleToggleLayout() {
|
|||||||
<header ref="headerRef" class="app-nav" :class="{ 'app-nav--hidden': hidden }">
|
<header ref="headerRef" class="app-nav" :class="{ 'app-nav--hidden': hidden }">
|
||||||
<div class="app-nav__wrapper">
|
<div class="app-nav__wrapper">
|
||||||
<span class="app-nav__title">RSS Reader<span v-if="unreadCount" class="app-nav__unread"> ({{ unreadCount }})</span></span>
|
<span class="app-nav__title">RSS Reader<span v-if="unreadCount" class="app-nav__unread"> ({{ unreadCount }})</span></span>
|
||||||
|
<span
|
||||||
|
class="app-nav__sync-spinner"
|
||||||
|
:class="{ 'app-nav__sync-spinner--active': isSyncing }"
|
||||||
|
aria-hidden="true"
|
||||||
|
></span>
|
||||||
<select
|
<select
|
||||||
v-if="onFeedsPage && feedTitles.length"
|
v-if="onFeedsPage && feedTitles.length"
|
||||||
class="app-nav__filter"
|
class="app-nav__filter"
|
||||||
@@ -251,6 +256,34 @@ function handleToggleLayout() {
|
|||||||
opacity: 0.6;
|
opacity: 0.6;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.app-nav__sync-spinner {
|
||||||
|
width: 16px;
|
||||||
|
height: 16px;
|
||||||
|
border: 2px solid var(--color-border);
|
||||||
|
border-top-color: var(--color-text);
|
||||||
|
border-radius: 50%;
|
||||||
|
opacity: 0.6;
|
||||||
|
flex-shrink: 0;
|
||||||
|
visibility: hidden;
|
||||||
|
}
|
||||||
|
|
||||||
|
.app-nav__sync-spinner--active {
|
||||||
|
visibility: visible;
|
||||||
|
animation: app-nav-spin 0.8s linear infinite;
|
||||||
|
}
|
||||||
|
|
||||||
|
@keyframes app-nav-spin {
|
||||||
|
to {
|
||||||
|
transform: rotate(360deg);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (prefers-reduced-motion: reduce) {
|
||||||
|
.app-nav__sync-spinner--active {
|
||||||
|
animation: none;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
.app-nav__filter {
|
.app-nav__filter {
|
||||||
min-height: 44px;
|
min-height: 44px;
|
||||||
max-width: clamp(120px, 40vw, 220px);
|
max-width: clamp(120px, 40vw, 220px);
|
||||||
|
|||||||
@@ -310,6 +310,48 @@ describe('AppNav', () => {
|
|||||||
expect(wrapper.find('.app-nav__title').text()).toContain('(2)')
|
expect(wrapper.find('.app-nav__title').text()).toContain('(2)')
|
||||||
})
|
})
|
||||||
|
|
||||||
|
describe('sync spinner', () => {
|
||||||
|
it('always renders the spinner, inactive, when idle', async () => {
|
||||||
|
const wrapper = mountNav()
|
||||||
|
await flushPromises()
|
||||||
|
|
||||||
|
const spinner = wrapper.find('.app-nav__sync-spinner')
|
||||||
|
expect(spinner.exists()).toBe(true)
|
||||||
|
expect(spinner.classes()).not.toContain('app-nav__sync-spinner--active')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('activates while a sync triggered through the composable is in flight, and deactivates once it resolves', async () => {
|
||||||
|
const wrapper = mountNav()
|
||||||
|
await flushPromises()
|
||||||
|
|
||||||
|
let resolvePost
|
||||||
|
axios.post.mockReturnValueOnce(new Promise(resolve => { resolvePost = resolve }))
|
||||||
|
const { sync } = useFeeds()
|
||||||
|
const syncPromise = sync(true)
|
||||||
|
await nextTick()
|
||||||
|
|
||||||
|
expect(wrapper.find('.app-nav__sync-spinner').classes()).toContain('app-nav__sync-spinner--active')
|
||||||
|
|
||||||
|
axios.get.mockResolvedValueOnce({ data: { feeds: [] } })
|
||||||
|
resolvePost({ status: 200 })
|
||||||
|
await syncPromise
|
||||||
|
await nextTick()
|
||||||
|
|
||||||
|
expect(wrapper.find('.app-nav__sync-spinner').classes()).not.toContain('app-nav__sync-spinner--active')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('renders even on a page where the feed filter itself does not render', async () => {
|
||||||
|
await router.push('/login')
|
||||||
|
await router.isReady()
|
||||||
|
|
||||||
|
const wrapper = mountNav()
|
||||||
|
await flushPromises()
|
||||||
|
|
||||||
|
expect(wrapper.find('.app-nav__filter').exists()).toBe(false)
|
||||||
|
expect(wrapper.find('.app-nav__sync-spinner').exists()).toBe(true)
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
describe('scroll-driven show/hide', () => {
|
describe('scroll-driven show/hide', () => {
|
||||||
// The scroll handler is rAF-throttled; run rAF synchronously so a single
|
// The scroll handler is rAF-throttled; run rAF synchronously so a single
|
||||||
// dispatched scroll event resolves before we assert. Per the CLAUDE.md
|
// dispatched scroll event resolves before we assert. Per the CLAUDE.md
|
||||||
|
|||||||
@@ -12,7 +12,7 @@ class FakeIntersectionObserver {
|
|||||||
vi.stubGlobal('IntersectionObserver', FakeIntersectionObserver)
|
vi.stubGlobal('IntersectionObserver', FakeIntersectionObserver)
|
||||||
|
|
||||||
describe('useFeeds', () => {
|
describe('useFeeds', () => {
|
||||||
const { feeds, allItems, feedFilter, feedTitles, feedUnreadCounts, unreadCount, displayedFeedUnreadCounts, refreshUnreadDisplay, setFeedFilter, showMessage, message, viewMode, currentIndex, fetchData, sync, getReadable, setInitialLoad, handleIntersection, markAllRead, markRead, markCurrentArticleRead, setupIntersectionObserver } = useFeeds()
|
const { feeds, allItems, feedFilter, feedTitles, feedUnreadCounts, unreadCount, displayedFeedUnreadCounts, refreshUnreadDisplay, setFeedFilter, showMessage, message, viewMode, currentIndex, fetchData, sync, getReadable, setInitialLoad, handleIntersection, markAllRead, markRead, markCurrentArticleRead, setupIntersectionObserver, isSyncing } = useFeeds()
|
||||||
|
|
||||||
beforeEach(() => {
|
beforeEach(() => {
|
||||||
localStorage.setItem('user-token', 'test-token')
|
localStorage.setItem('user-token', 'test-token')
|
||||||
@@ -289,7 +289,7 @@ describe('useFeeds', () => {
|
|||||||
axios.get.mockReturnValueOnce(new Promise(resolve => { resolveGet = resolve }))
|
axios.get.mockReturnValueOnce(new Promise(resolve => { resolveGet = resolve }))
|
||||||
const fetchPromise = fetchData()
|
const fetchPromise = fetchData()
|
||||||
|
|
||||||
markCurrentArticleRead() // queued: isSyncing() is true
|
markCurrentArticleRead() // queued: isSyncing is true
|
||||||
// The user backs out to list view before the reload finishes —
|
// The user backs out to list view before the reload finishes —
|
||||||
// leaveArticleView()'s dropReadArticles() runs here, but this article
|
// leaveArticleView()'s dropReadArticles() runs here, but this article
|
||||||
// isn't flagged read yet (it's only queued), so it survives that pass.
|
// isn't flagged read yet (it's only queued), so it survives that pass.
|
||||||
@@ -366,6 +366,90 @@ describe('useFeeds', () => {
|
|||||||
})
|
})
|
||||||
})
|
})
|
||||||
|
|
||||||
|
describe('isSyncing', () => {
|
||||||
|
// Lets a just-resolved deferred promise's `.then` continuations (e.g.
|
||||||
|
// sync()'s `await fetchData()`) actually run before the next assertion,
|
||||||
|
// without pulling in fake timers (see the CLAUDE.md Vitest gotcha).
|
||||||
|
async function flushMicrotasks() {
|
||||||
|
for (let i = 0; i < 5; i++) await Promise.resolve()
|
||||||
|
}
|
||||||
|
|
||||||
|
it('is false initially, true while a fetchData() call is in flight, and false again once it resolves', async () => {
|
||||||
|
expect(isSyncing.value).toBe(false)
|
||||||
|
|
||||||
|
let resolveGet
|
||||||
|
axios.get.mockReturnValueOnce(new Promise(resolve => { resolveGet = resolve }))
|
||||||
|
const fetchPromise = fetchData()
|
||||||
|
|
||||||
|
expect(isSyncing.value).toBe(true)
|
||||||
|
|
||||||
|
resolveGet({ data: { feeds: [] } })
|
||||||
|
await fetchPromise
|
||||||
|
|
||||||
|
expect(isSyncing.value).toBe(false)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('stays true for the whole duration of sync(), including its trailing fetchData(), and only clears once sync() fully settles', async () => {
|
||||||
|
let resolvePost
|
||||||
|
axios.post.mockReturnValueOnce(new Promise(resolve => { resolvePost = resolve }))
|
||||||
|
let resolveGet
|
||||||
|
axios.get.mockReturnValueOnce(new Promise(resolve => { resolveGet = resolve }))
|
||||||
|
|
||||||
|
const syncPromise = sync(true)
|
||||||
|
expect(isSyncing.value).toBe(true)
|
||||||
|
|
||||||
|
// The sync POST settles, handing off into sync()'s own trailing
|
||||||
|
// fetchData() — isSyncing must not flicker false in between.
|
||||||
|
resolvePost({ status: 200 })
|
||||||
|
await flushMicrotasks()
|
||||||
|
|
||||||
|
expect(axios.get).toHaveBeenCalled()
|
||||||
|
expect(isSyncing.value).toBe(true)
|
||||||
|
|
||||||
|
resolveGet({ data: { feeds: [] } })
|
||||||
|
await syncPromise
|
||||||
|
|
||||||
|
expect(isSyncing.value).toBe(false)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('leaves isSyncing true while an independent fetchData() completes during a still in-flight sync(), clearing only once both finish', async () => {
|
||||||
|
let resolvePost
|
||||||
|
axios.post.mockReturnValueOnce(new Promise(resolve => { resolvePost = resolve }))
|
||||||
|
const syncPromise = sync(true)
|
||||||
|
expect(isSyncing.value).toBe(true)
|
||||||
|
|
||||||
|
// An independent fetchData() call runs to completion while sync()'s
|
||||||
|
// POST is still pending.
|
||||||
|
axios.get.mockResolvedValueOnce({ data: { feeds: [] } })
|
||||||
|
await fetchData()
|
||||||
|
|
||||||
|
// sync() itself hasn't finished yet.
|
||||||
|
expect(isSyncing.value).toBe(true)
|
||||||
|
|
||||||
|
axios.get.mockResolvedValueOnce({ data: { feeds: [] } })
|
||||||
|
resolvePost({ status: 200 })
|
||||||
|
await syncPromise
|
||||||
|
|
||||||
|
expect(isSyncing.value).toBe(false)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('clears on a rejected fetchData()', async () => {
|
||||||
|
axios.get.mockRejectedValueOnce(new Error('network'))
|
||||||
|
|
||||||
|
await fetchData() // caught internally — resolves rather than rejecting
|
||||||
|
|
||||||
|
expect(isSyncing.value).toBe(false)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('clears on a rejected sync()', async () => {
|
||||||
|
axios.post.mockRejectedValueOnce(new Error('network'))
|
||||||
|
|
||||||
|
await sync(true) // caught internally — resolves rather than rejecting
|
||||||
|
|
||||||
|
expect(isSyncing.value).toBe(false)
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
describe('markRead resilience', () => {
|
describe('markRead resilience', () => {
|
||||||
// Mirrors useFeeds.js's MARK_READ_RETRY_DELAYS_MS (not exported). Kept in
|
// Mirrors useFeeds.js's MARK_READ_RETRY_DELAYS_MS (not exported). Kept in
|
||||||
// sync via this comment: if that backoff changes, update this too — too
|
// sync via this comment: if that backoff changes, update this too — too
|
||||||
|
|||||||
@@ -86,12 +86,12 @@ let skipNextObservation = false
|
|||||||
// markCurrentArticleRead's display-marking — must not fire while any is in
|
// markCurrentArticleRead's display-marking — must not fire while any is in
|
||||||
// flight. markAllRead is a deliberate, already-confirmed action and is left
|
// flight. markAllRead is a deliberate, already-confirmed action and is left
|
||||||
// ungated.
|
// ungated.
|
||||||
let activeSyncCount = 0
|
const activeSyncCount = ref(0)
|
||||||
function beginSync() {
|
function beginSync() {
|
||||||
activeSyncCount += 1
|
activeSyncCount.value += 1
|
||||||
}
|
}
|
||||||
// Ids handleIntersection()/markCurrentArticleRead() couldn't mark while
|
// Ids handleIntersection()/markCurrentArticleRead() couldn't mark while
|
||||||
// isSyncing() (see below) — flushed once every in-flight fetchData()/sync()
|
// isSyncing (see below) — flushed once every in-flight fetchData()/sync()
|
||||||
// has finished, rather than left unread forever. Flushing by id (not by
|
// has finished, rather than left unread forever. Flushing by id (not by
|
||||||
// re-deriving an index) sidesteps fetchData() having replaced
|
// re-deriving an index) sidesteps fetchData() having replaced
|
||||||
// feeds.value/allItems.value wholesale in the meantime — the id is still
|
// feeds.value/allItems.value wholesale in the meantime — the id is still
|
||||||
@@ -99,14 +99,12 @@ function beginSync() {
|
|||||||
const pendingSuppressedReadIds = new Set()
|
const pendingSuppressedReadIds = new Set()
|
||||||
|
|
||||||
function endSync() {
|
function endSync() {
|
||||||
activeSyncCount -= 1
|
activeSyncCount.value -= 1
|
||||||
if (activeSyncCount === 0) {
|
if (activeSyncCount.value === 0) {
|
||||||
flushPendingSuppressedReads()
|
flushPendingSuppressedReads()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
function isSyncing() {
|
const isSyncing = computed(() => activeSyncCount.value > 0)
|
||||||
return activeSyncCount > 0
|
|
||||||
}
|
|
||||||
|
|
||||||
function flushPendingSuppressedReads() {
|
function flushPendingSuppressedReads() {
|
||||||
if (pendingSuppressedReadIds.size === 0) return
|
if (pendingSuppressedReadIds.size === 0) return
|
||||||
@@ -509,7 +507,7 @@ function handleIntersection(entries, topbarHeight = 0) {
|
|||||||
|
|
||||||
if (readFeeds.length === 0) return
|
if (readFeeds.length === 0) return
|
||||||
|
|
||||||
if (isSyncing()) {
|
if (isSyncing.value) {
|
||||||
// Don't mutate a list that's mid-reload — queue these ids instead of
|
// Don't mutate a list that's mid-reload — queue these ids instead of
|
||||||
// dropping them; flushPendingSuppressedReads() marks (and removes) them
|
// dropping them; flushPendingSuppressedReads() marks (and removes) them
|
||||||
// once every in-flight fetchData()/sync() has finished. Nothing here
|
// once every in-flight fetchData()/sync() has finished. Nothing here
|
||||||
@@ -604,7 +602,7 @@ function markCurrentArticleRead() {
|
|||||||
// Don't mark against a list that's mid-reload — see isSyncing/activeSyncCount.
|
// Don't mark against a list that's mid-reload — see isSyncing/activeSyncCount.
|
||||||
// Queue this article's id instead of dropping it: flushPendingSuppressedReads()
|
// Queue this article's id instead of dropping it: flushPendingSuppressedReads()
|
||||||
// marks it once every in-flight fetchData()/sync() has finished.
|
// marks it once every in-flight fetchData()/sync() has finished.
|
||||||
if (isSyncing()) {
|
if (isSyncing.value) {
|
||||||
pendingSuppressedReadIds.add(feed.id)
|
pendingSuppressedReadIds.add(feed.id)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
@@ -711,5 +709,6 @@ export function useFeeds() {
|
|||||||
handleIntersection,
|
handleIntersection,
|
||||||
lastProgrammaticScroll,
|
lastProgrammaticScroll,
|
||||||
markProgrammaticScroll,
|
markProgrammaticScroll,
|
||||||
|
isSyncing,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user