mirror of
https://github.com/bookorbit/bookorbit.git
synced 2026-11-03 08:21:39 -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 { 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) }) }}
|
||||||
|
|||||||
Reference in New Issue
Block a user