fix(series): keep loading series books after returning to the page

Read the series id once per kept-alive instance instead of following the
shared route, so coming back no longer resets the list and strands the
scroll observer on a removed sentinel. Re-observe the sentinel whenever
the books section remounts.

Closes #1988
This commit is contained in:
neon
2026-10-30 00:13:16 +00:00
parent 0206a98ccc
commit e8e3f4df19
2 changed files with 57 additions and 35 deletions
@@ -1,13 +1,17 @@
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import { computed, defineComponent, nextTick, reactive, ref, type PropType } from 'vue' import { computed, defineComponent, nextTick, reactive, ref, type PropType } from 'vue'
import { flushPromises, mount } from '@vue/test-utils' import { flushPromises, mount } from '@vue/test-utils'
import type { BookCard, SeriesBooksPage, SeriesDetail } from '@bookorbit/types' import type { BookCard, SeriesDetail } from '@bookorbit/types'
import SeriesDetailView from './SeriesDetailView.vue' import SeriesDetailView from './SeriesDetailView.vue'
import SeriesCompletionBar from '../components/SeriesCompletionBar.vue' import SeriesCompletionBar from '../components/SeriesCompletionBar.vue'
const GROUP_BY_MEDIA_STORAGE_KEY = 'bookorbit:series-detail:group-by-media' const GROUP_BY_MEDIA_STORAGE_KEY = 'bookorbit:series-detail:group-by-media'
class MockIntersectionObserver { class MockIntersectionObserver {
static instances: MockIntersectionObserver[] = []
constructor() {
MockIntersectionObserver.instances.push(this)
}
observe = vi.fn<(target: Element) => void>() observe = vi.fn<(target: Element) => void>()
unobserve = vi.fn<(target: Element) => void>() unobserve = vi.fn<(target: Element) => void>()
disconnect = vi.fn<() => void>() disconnect = vi.fn<() => void>()
@@ -303,6 +307,7 @@ describe('SeriesDetailView', () => {
mocks.api.mockReset() mocks.api.mockReset()
mocks.api.mockResolvedValue({ ok: false }) mocks.api.mockResolvedValue({ ok: false })
MockIntersectionObserver.instances = []
vi.stubGlobal('IntersectionObserver', MockIntersectionObserver) vi.stubGlobal('IntersectionObserver', MockIntersectionObserver)
}) })
@@ -541,32 +546,41 @@ describe('SeriesDetailView', () => {
expect(secondStyle).toBe(style) expect(secondStyle).toBe(style)
}) })
it('ignores stale lead preview responses after route id becomes invalid', async () => { it('keeps its loaded books when the route moves on and back while the view is kept alive', async () => {
let resolveLeadPreview!: (value: SeriesBooksPage) => void mountView()
mocks.fetchSeriesBooks.mockImplementationOnce( await flushPromises()
() => mocks.loadBooks.mockClear()
new Promise<SeriesBooksPage>((resolve) => { mocks.fetchSeriesBooks.mockClear()
resolveLeadPreview = resolve
}),
)
const wrapper = mountView() mocks.route.params.seriesId = ''
await nextTick() await flushPromises()
mocks.route.params.seriesId = '99'
mocks.route.params.seriesId = 'invalid' await flushPromises()
await nextTick() mocks.route.params.seriesId = '42'
resolveLeadPreview({
items: [makeBook({ id: 7, seriesIndex: '1', hasCover: true })],
total: 1,
page: 0,
size: 8,
seriesInfo: makeSeriesInfo(),
})
await flushPromises() await flushPromises()
expect(mocks.api).not.toHaveBeenCalled() expect(mocks.loadBooks).not.toHaveBeenCalled()
expect(wrapper.find('[data-testid="lead-cover-artwork"]').exists()).toBe(false) expect(mocks.fetchSeriesBooks).not.toHaveBeenCalled()
})
it('observes the replacement sentinel when the books section remounts', async () => {
const wrapper = mountView()
await flushPromises()
const observer = MockIntersectionObserver.instances[0]!
const firstSentinel = wrapper.get('[data-testid="series-books-sentinel"]').element
expect(observer.observe).toHaveBeenLastCalledWith(firstSentinel)
mocks.seriesInfo.value = null
await flushPromises()
expect(observer.unobserve).toHaveBeenCalledWith(firstSentinel)
mocks.seriesInfo.value = makeSeriesInfo()
await flushPromises()
const nextSentinel = wrapper.get('[data-testid="series-books-sentinel"]').element
expect(nextSentinel).not.toBe(firstSentinel)
expect(observer.observe).toHaveBeenLastCalledWith(nextSentinel)
}) })
it('loads all books and navigates to edit metadata, preserving the setBookContext when navigating away', async () => { it('loads all books and navigates to edit metadata, preserving the setBookContext when navigating away', async () => {
@@ -1,5 +1,5 @@
<script setup lang="ts"> <script setup lang="ts">
import { computed, nextTick, onMounted, onUnmounted, ref, watch } from 'vue' import { computed, nextTick, onMounted, onUnmounted, ref, shallowRef, watch } from 'vue'
import { useI18n } from 'vue-i18n' import { useI18n } from 'vue-i18n'
import { formatNumber } from '@/i18n/formatters' import { formatNumber } from '@/i18n/formatters'
import { useRoute, useRouter } from 'vue-router' import { useRoute, useRouter } from 'vue-router'
@@ -54,11 +54,14 @@ const { coverUrl } = useCoverVersions()
const { portraitCoverSize, gridGap } = useDisplaySettings() const { portraitCoverSize, gridGap } = useDisplaySettings()
const { libraries, fetchLibraries } = useLibraries() const { libraries, fetchLibraries } = useLibraries()
const seriesId = computed(() => { function parseSeriesId(raw: unknown): number | null {
const raw = route.params.seriesId
const value = typeof raw === 'string' ? Number(raw) : NaN const value = typeof raw === 'string' ? Number(raw) : NaN
return Number.isInteger(value) && value > 0 ? value : null return Number.isInteger(value) && value > 0 ? value : null
}) }
// The shell keeps this view alive per path, so the id is fixed for the instance. Following the shared
// route would reset the list every time the reader leaves and comes back.
const seriesId = shallowRef(parseSeriesId(route.params.seriesId))
const { const {
seriesInfo, seriesInfo,
@@ -417,6 +420,17 @@ onUnmounted(() => {
observer?.disconnect() observer?.disconnect()
}) })
// Anything that remounts the books section replaces the sentinel, and the observer has to follow it.
watch(
sentinel,
(element, previous) => {
if (!observer) return
if (previous) observer.unobserve(previous)
if (element) observer.observe(element)
},
{ flush: 'post' },
)
watch( watch(
[books, total], [books, total],
([newBooks, newTotal]) => { ([newBooks, newTotal]) => {
@@ -433,12 +447,6 @@ watch([sort, order, libraryId], () => {
void loadBooks({ reset: true, keepPreviousData: true }) void loadBooks({ reset: true, keepPreviousData: true })
}) })
watch(seriesId, (newSeriesId) => {
if (newSeriesId !== null) {
void loadBooks({ reset: true })
}
})
watch( watch(
[seriesId, libraryId], [seriesId, libraryId],
([nextSeriesId], [prevSeriesId]) => { ([nextSeriesId], [prevSeriesId]) => {
@@ -720,7 +728,7 @@ defineOptions({ name: 'SeriesDetailView' })
/> />
</template> </template>
<div ref="sentinel" class="mt-4 flex h-8 items-center justify-center"> <div ref="sentinel" data-testid="series-books-sentinel" class="mt-4 flex h-8 items-center justify-center">
<span v-if="loadingBooks" class="text-xs text-muted-foreground">{{ t('common.loading') }}</span> <span v-if="loadingBooks" class="text-xs text-muted-foreground">{{ t('common.loading') }}</span>
<span v-else-if="!hasMore && books.length > 0" class="text-xs text-muted-foreground"> <span v-else-if="!hasMore && books.length > 0" class="text-xs text-muted-foreground">
{{ t('series.detail.allBooksLoaded', { count: formatNumber(total) }) }} {{ t('series.detail.allBooksLoaded', { count: formatNumber(total) }) }}