mirror of
https://github.com/bookorbit/bookorbit.git
synced 2026-11-03 00:11:37 -06:00
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:
@@ -1,13 +1,17 @@
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import { computed, defineComponent, nextTick, reactive, ref, type PropType } from 'vue'
|
||||
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 SeriesCompletionBar from '../components/SeriesCompletionBar.vue'
|
||||
|
||||
const GROUP_BY_MEDIA_STORAGE_KEY = 'bookorbit:series-detail:group-by-media'
|
||||
|
||||
class MockIntersectionObserver {
|
||||
static instances: MockIntersectionObserver[] = []
|
||||
constructor() {
|
||||
MockIntersectionObserver.instances.push(this)
|
||||
}
|
||||
observe = vi.fn<(target: Element) => void>()
|
||||
unobserve = vi.fn<(target: Element) => void>()
|
||||
disconnect = vi.fn<() => void>()
|
||||
@@ -303,6 +307,7 @@ describe('SeriesDetailView', () => {
|
||||
mocks.api.mockReset()
|
||||
mocks.api.mockResolvedValue({ ok: false })
|
||||
|
||||
MockIntersectionObserver.instances = []
|
||||
vi.stubGlobal('IntersectionObserver', MockIntersectionObserver)
|
||||
})
|
||||
|
||||
@@ -541,32 +546,41 @@ describe('SeriesDetailView', () => {
|
||||
expect(secondStyle).toBe(style)
|
||||
})
|
||||
|
||||
it('ignores stale lead preview responses after route id becomes invalid', async () => {
|
||||
let resolveLeadPreview!: (value: SeriesBooksPage) => void
|
||||
mocks.fetchSeriesBooks.mockImplementationOnce(
|
||||
() =>
|
||||
new Promise<SeriesBooksPage>((resolve) => {
|
||||
resolveLeadPreview = resolve
|
||||
}),
|
||||
)
|
||||
it('keeps its loaded books when the route moves on and back while the view is kept alive', async () => {
|
||||
mountView()
|
||||
await flushPromises()
|
||||
mocks.loadBooks.mockClear()
|
||||
mocks.fetchSeriesBooks.mockClear()
|
||||
|
||||
const wrapper = mountView()
|
||||
await nextTick()
|
||||
|
||||
mocks.route.params.seriesId = 'invalid'
|
||||
await nextTick()
|
||||
|
||||
resolveLeadPreview({
|
||||
items: [makeBook({ id: 7, seriesIndex: '1', hasCover: true })],
|
||||
total: 1,
|
||||
page: 0,
|
||||
size: 8,
|
||||
seriesInfo: makeSeriesInfo(),
|
||||
})
|
||||
mocks.route.params.seriesId = ''
|
||||
await flushPromises()
|
||||
mocks.route.params.seriesId = '99'
|
||||
await flushPromises()
|
||||
mocks.route.params.seriesId = '42'
|
||||
await flushPromises()
|
||||
|
||||
expect(mocks.api).not.toHaveBeenCalled()
|
||||
expect(wrapper.find('[data-testid="lead-cover-artwork"]').exists()).toBe(false)
|
||||
expect(mocks.loadBooks).not.toHaveBeenCalled()
|
||||
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 () => {
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
<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 { formatNumber } from '@/i18n/formatters'
|
||||
import { useRoute, useRouter } from 'vue-router'
|
||||
@@ -54,11 +54,14 @@ const { coverUrl } = useCoverVersions()
|
||||
const { portraitCoverSize, gridGap } = useDisplaySettings()
|
||||
const { libraries, fetchLibraries } = useLibraries()
|
||||
|
||||
const seriesId = computed(() => {
|
||||
const raw = route.params.seriesId
|
||||
function parseSeriesId(raw: unknown): number | null {
|
||||
const value = typeof raw === 'string' ? Number(raw) : NaN
|
||||
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 {
|
||||
seriesInfo,
|
||||
@@ -417,6 +420,17 @@ onUnmounted(() => {
|
||||
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(
|
||||
[books, total],
|
||||
([newBooks, newTotal]) => {
|
||||
@@ -433,12 +447,6 @@ watch([sort, order, libraryId], () => {
|
||||
void loadBooks({ reset: true, keepPreviousData: true })
|
||||
})
|
||||
|
||||
watch(seriesId, (newSeriesId) => {
|
||||
if (newSeriesId !== null) {
|
||||
void loadBooks({ reset: true })
|
||||
}
|
||||
})
|
||||
|
||||
watch(
|
||||
[seriesId, libraryId],
|
||||
([nextSeriesId], [prevSeriesId]) => {
|
||||
@@ -720,7 +728,7 @@ defineOptions({ name: 'SeriesDetailView' })
|
||||
/>
|
||||
</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-else-if="!hasMore && books.length > 0" class="text-xs text-muted-foreground">
|
||||
{{ t('series.detail.allBooksLoaded', { count: formatNumber(total) }) }}
|
||||
|
||||
Reference in New Issue
Block a user