fix(book-detail): stop the reading log and highlights tabs flashing

Both tabs fetch on mount and were rebuilt on every switch, so each visit
replayed blank, skeleton and content inside 120ms. Keep them alive and
revalidate silently instead, hold the layout until the first load decides
which shape it settles into, and defer skeletons past 220ms.

Also fixes the unhandled rejection failing the server suite in CI: the
Goodreads bot-challenge test rejected before its assertion was attached.
This commit is contained in:
neon
2026-08-26 19:53:36 +00:00
parent 87a3217c5e
commit 8b9442be1f
9 changed files with 241 additions and 51 deletions
@@ -0,0 +1,64 @@
import { onScopeDispose, ref, watch, type Ref } from 'vue'
const SHOW_AFTER_MS = 220
const MIN_VISIBLE_MS = 320
/**
* Gates a loading placeholder on how long the load actually takes.
*
* A skeleton that paints and clears inside a couple of frames is not feedback, it is a flash: the
* book detail tabs answer in well under 100ms locally, so binding shimmer straight to `loading`
* strobes the pane on every visit. This stays down until the wait is long enough to be worth
* announcing, and once raised it holds long enough to be read rather than blinking off again.
*/
export function useDeferredLoading(loading: Ref<boolean>, options?: { showAfterMs?: number; minVisibleMs?: number }): Ref<boolean> {
const showAfterMs = options?.showAfterMs ?? SHOW_AFTER_MS
const minVisibleMs = options?.minVisibleMs ?? MIN_VISIBLE_MS
const visible = ref(false)
let showTimer: ReturnType<typeof setTimeout> | null = null
let hideTimer: ReturnType<typeof setTimeout> | null = null
let shownAt = 0
function clearTimers() {
if (showTimer !== null) {
clearTimeout(showTimer)
showTimer = null
}
if (hideTimer !== null) {
clearTimeout(hideTimer)
hideTimer = null
}
}
watch(
loading,
(busy) => {
clearTimers()
if (busy) {
if (visible.value) return
showTimer = setTimeout(() => {
showTimer = null
shownAt = performance.now()
visible.value = true
}, showAfterMs)
return
}
if (!visible.value) return
const remaining = minVisibleMs - (performance.now() - shownAt)
if (remaining <= 0) {
visible.value = false
return
}
hideTimer = setTimeout(() => {
hideTimer = null
visible.value = false
}, remaining)
},
{ immediate: true },
)
onScopeDispose(clearTimers)
return visible
}
@@ -273,24 +273,38 @@ const formatLine = computed(() =>
const currentStatusOption = computed(() => STATUS_OPTIONS.find((option) => option.value === (localReadStatus.value ?? 'unread'))) const currentStatusOption = computed(() => STATUS_OPTIONS.find((option) => option.value === (localReadStatus.value ?? 'unread')))
// A book with no sessions still answers with a stats object of zeros, so a null one means the
// first load has not landed. Printing its zeros claims the book is unread until the real count
// arrives; the dash the pace and last-read rows already use says "not known yet" instead.
const statsPending = computed(() => props.stats === null)
const ledgerRows = computed(() => [ const ledgerRows = computed(() => [
{ {
key: 'total', key: 'total',
label: t('book.detail.readingLog.vitals.totalTime'), label: t('book.detail.readingLog.vitals.totalTime'),
value: formatDuration(props.stats?.totalSeconds ?? 0), value: statsPending.value ? '-' : formatDuration(props.stats?.totalSeconds ?? 0),
withMomentum: true, withMomentum: true,
}, },
{ key: 'sessions', label: t('book.detail.readingLog.vitals.sessions'), value: formatNumber(props.stats?.totalSessions ?? 0), withMomentum: false }, {
key: 'sessions',
label: t('book.detail.readingLog.vitals.sessions'),
value: statsPending.value ? '-' : formatNumber(props.stats?.totalSessions ?? 0),
withMomentum: false,
},
{ {
key: 'average', key: 'average',
label: t('book.detail.readingLog.vitals.avgSession'), label: t('book.detail.readingLog.vitals.avgSession'),
value: formatDuration(props.stats?.avgDurationSeconds ?? 0), value: statsPending.value ? '-' : formatDuration(props.stats?.avgDurationSeconds ?? 0),
withMomentum: false, withMomentum: false,
}, },
{ {
key: 'activeDays', key: 'activeDays',
label: t('book.detail.readingLog.vitals.activeDays'), label: t('book.detail.readingLog.vitals.activeDays'),
value: spanDays.value > 0 ? t('book.detail.readingLog.vitals.activeDaysOf', { active: activeDays.value, total: spanDays.value }) : '0', value: statsPending.value
? '-'
: spanDays.value > 0
? t('book.detail.readingLog.vitals.activeDaysOf', { active: activeDays.value, total: spanDays.value })
: '0',
withMomentum: false, withMomentum: false,
}, },
{ {
@@ -1,11 +1,12 @@
<script setup lang="ts"> <script setup lang="ts">
import { computed, onBeforeUnmount, onMounted, ref, watch } from 'vue' import { computed, onActivated, onBeforeUnmount, onDeactivated, onMounted, ref, watch } from 'vue'
import { useMediaQuery } from '@vueuse/core' import { useMediaQuery } from '@vueuse/core'
import { useRouter } from 'vue-router' import { useRouter } from 'vue-router'
import { useI18n } from 'vue-i18n' import { useI18n } from 'vue-i18n'
import type { AnnotationItem, BookDetail } from '@bookorbit/types' import type { AnnotationItem, BookDetail } from '@bookorbit/types'
import { READER_OPENABLE_FORMATS } from '@bookorbit/types' import { READER_OPENABLE_FORMATS } from '@bookorbit/types'
import { useBookHighlights } from '@/features/book/composables/useBookHighlights' import { useBookHighlights } from '@/features/book/composables/useBookHighlights'
import { useDeferredLoading } from '@/composables/useDeferredLoading'
import { useDensity } from '@/features/annotations/composables/useDensity' import { useDensity } from '@/features/annotations/composables/useDensity'
import { HIGHLIGHT_GROUP_MODES, NO_CHAPTER_KEY, type HighlightGroup } from '@/features/book/lib/highlight-groups' import { HIGHLIGHT_GROUP_MODES, NO_CHAPTER_KEY, type HighlightGroup } from '@/features/book/lib/highlight-groups'
import HighlightsRail from '@/features/book/components/detail/highlights/HighlightsRail.vue' import HighlightsRail from '@/features/book/components/detail/highlights/HighlightsRail.vue'
@@ -17,6 +18,9 @@ import HighlightsBand from '@/features/book/components/detail/highlights/Highlig
import HighlightsEmptyStage from '@/features/book/components/detail/highlights/HighlightsEmptyStage.vue' import HighlightsEmptyStage from '@/features/book/components/detail/highlights/HighlightsEmptyStage.vue'
import HighlightsShortcutsDialog from '@/features/book/components/detail/highlights/HighlightsShortcutsDialog.vue' import HighlightsShortcutsDialog from '@/features/book/components/detail/highlights/HighlightsShortcutsDialog.vue'
// Named for the KeepAlive `include` list in BookDetailView.
defineOptions({ name: 'HighlightsTab' })
const props = defineProps<{ book: BookDetail }>() const props = defineProps<{ book: BookDetail }>()
const { t } = useI18n() const { t } = useI18n()
@@ -33,6 +37,17 @@ const editingNoteId = ref<number | null>(null)
const bookTitle = computed(() => props.book.title ?? t('book.detail.highlights.untitled')) const bookTitle = computed(() => props.book.title ?? t('book.detail.highlights.untitled'))
const blank = computed(() => !hl.loading.value && hl.total.value === 0 && !hl.hasActiveFilters.value) const blank = computed(() => !hl.loading.value && hl.total.value === 0 && !hl.hasActiveFilters.value)
// Until the first load lands there is nothing to print but zeros, and a rail reading "0 highlights"
// over an empty stream is a claim, not a loading state. Latched, because a later filter reload
// must not throw the pane back to the placeholder, which covers the box either settled layout
// puts there so neither resolution moves anything.
const resolved = ref(false)
watch(hl.loading, (busy) => {
if (!busy) resolved.value = true
})
const firstLoadPending = computed(() => !resolved.value)
const showFirstLoadSkeleton = useDeferredLoading(firstLoadPending)
const stackedDetail = computed(() => isStacked.value && hl.activeItem.value != null) const stackedDetail = computed(() => isStacked.value && hl.activeItem.value != null)
const readableFile = computed(() => props.book.files.find((file) => file.format != null && READER_OPENABLE_FORMATS.has(file.format as never)) ?? null) const readableFile = computed(() => props.book.files.find((file) => file.format != null && READER_OPENABLE_FORMATS.has(file.format as never)) ?? null)
@@ -192,8 +207,29 @@ watch(
}, },
) )
onMounted(() => window.addEventListener('keydown', handleKeydown)) // The tab is kept alive, so a cached-but-hidden Highlights tab must stop swallowing j/k/? while
onBeforeUnmount(() => window.removeEventListener('keydown', handleKeydown)) // another tab is on screen. Mount and unmount stay bound too, for rendering outside a KeepAlive.
function bindShortcuts() {
window.addEventListener('keydown', handleKeydown)
}
function unbindShortcuts() {
window.removeEventListener('keydown', handleKeydown)
}
onMounted(bindShortcuts)
onBeforeUnmount(unbindShortcuts)
onDeactivated(unbindShortcuts)
let activatedBefore = false
onActivated(() => {
bindShortcuts()
if (!activatedBefore) {
activatedBefore = true
return
}
// A sync can add highlights while another tab is open. Silent, because the stream already holds
// the right rows and a skeleton over them is the flash this avoids.
void hl.fetchHighlights({ silent: true })
})
</script> </script>
<template> <template>
@@ -202,7 +238,13 @@ onBeforeUnmount(() => window.removeEventListener('keydown', handleKeydown))
{{ hl.error.value }} {{ hl.error.value }}
</div> </div>
<div v-if="blank" class="flex min-h-0 flex-1 flex-col"> <div v-if="!resolved" class="min-h-80 flex-1 rounded-xl border border-border bg-card xl:min-h-0" aria-busy="true">
<div v-if="showFirstLoadSkeleton" class="flex flex-col gap-2 px-3 py-3" aria-hidden="true">
<div v-for="row in 6" :key="row" class="h-4 rounded bg-muted animate-shimmer" />
</div>
</div>
<div v-else-if="blank" class="flex min-h-0 flex-1 flex-col">
<HighlightsEmptyStage :book-title="bookTitle" :can-read="readableFile != null" @read="handleOpenReader" /> <HighlightsEmptyStage :book-title="bookTitle" :can-read="readableFile != null" @read="handleOpenReader" />
</div> </div>
@@ -1,9 +1,10 @@
<script setup lang="ts"> <script setup lang="ts">
import { computed, nextTick, ref } from 'vue' import { computed, nextTick, onActivated, ref, watch } from 'vue'
import { useI18n } from 'vue-i18n' import { useI18n } from 'vue-i18n'
import { Ellipsis, RotateCcw } from '@lucide/vue' import { Ellipsis, RotateCcw } from '@lucide/vue'
import { Permission, type BookDetail, type UserBookStatus } from '@bookorbit/types' import { Permission, type BookDetail, type UserBookStatus } from '@bookorbit/types'
import { usePermissions } from '@/features/auth/composables/usePermissions' import { usePermissions } from '@/features/auth/composables/usePermissions'
import { useDeferredLoading } from '@/composables/useDeferredLoading'
import { useBookReadingLog, type AddReadingSessionPayload } from '@/features/book/composables/useBookReadingLog' import { useBookReadingLog, type AddReadingSessionPayload } from '@/features/book/composables/useBookReadingLog'
import { useReadingAttempts, type ReadingAttemptDraft } from '@/features/book/composables/useReadingAttempts' import { useReadingAttempts, type ReadingAttemptDraft } from '@/features/book/composables/useReadingAttempts'
import { useResetReadingState } from '@/features/book/composables/useResetReadingState' import { useResetReadingState } from '@/features/book/composables/useResetReadingState'
@@ -18,6 +19,9 @@ import ReadingLogEmptyStage from '../reading-log/ReadingLogEmptyStage.vue'
import ReadingLogExportMenu from './ReadingLogExportMenu.vue' import ReadingLogExportMenu from './ReadingLogExportMenu.vue'
import AddSessionDialog from './AddSessionDialog.vue' import AddSessionDialog from './AddSessionDialog.vue'
// Named for the KeepAlive `include` list in BookDetailView.
defineOptions({ name: 'ReadingLogTab' })
const props = defineProps<{ book: BookDetail }>() const props = defineProps<{ book: BookDetail }>()
const emit = defineEmits<{ const emit = defineEmits<{
@@ -57,6 +61,19 @@ const {
remove: removeAttempt, remove: removeAttempt,
} = useReadingAttempts(bookIdRef) } = useReadingAttempts(bookIdRef)
// The tab is kept alive, so returning to it shows the rows it already had rather than replaying
// the load. A sync can land a session while another tab is open, so revalidate on the way back -
// silently, because a skeleton over rows that are already correct is the flash this avoids.
let activatedBefore = false
onActivated(() => {
if (!activatedBefore) {
activatedBefore = true
return
}
void reload({ silent: true })
void reloadAttempts({ silent: true })
})
const { hasPermission } = usePermissions() const { hasPermission } = usePermissions()
const canManageReadingState = computed(() => hasPermission(Permission.LibraryEditMetadata)) const canManageReadingState = computed(() => hasPermission(Permission.LibraryEditMetadata))
const { const {
@@ -93,6 +110,17 @@ const blank = computed(
!emptyStageDismissed.value && !loading.value && !attemptsLoading.value && (stats.value?.totalSessions ?? 0) === 0 && attempts.value.length === 0, !emptyStageDismissed.value && !loading.value && !attemptsLoading.value && (stats.value?.totalSessions ?? 0) === 0 && attempts.value.length === 0,
) )
// The data decides which of two layouts the tab settles into, so until the first load lands there
// is no shape to commit to. Latched, because a later filter reload must not throw the pane back
// to the placeholder. The placeholder covers the same box either settled layout puts there, so
// whichever way it resolves nothing moves.
const resolved = ref(false)
watch([loading, attemptsLoading], ([sessionsBusy, attemptsBusy]) => {
if (!sessionsBusy && !attemptsBusy) resolved.value = true
})
const firstLoadPending = computed(() => !resolved.value)
const showFirstLoadSkeleton = useDeferredLoading(firstLoadPending)
function buildDateFrom(quick: QuickFilter): string | undefined { function buildDateFrom(quick: QuickFilter): string | undefined {
const now = new Date() const now = new Date()
if (quick === 'last30') return new Date(now.getTime() - 30 * 24 * 60 * 60 * 1000).toISOString() if (quick === 'last30') return new Date(now.getTime() - 30 * 24 * 60 * 60 * 1000).toISOString()
@@ -218,22 +246,38 @@ const quickFilters = computed<{ label: string; value: QuickFilter }[]>(() => [
<div <div
class="flex flex-col gap-4 xl:grid xl:min-h-0 xl:flex-1 xl:gap-x-5 xl:gap-y-4" class="flex flex-col gap-4 xl:grid xl:min-h-0 xl:flex-1 xl:gap-x-5 xl:gap-y-4"
:class=" :class="
blank blank && resolved
? 'xl:grid-cols-[17rem_minmax(0,1fr)] xl:grid-rows-[minmax(0,1fr)]' ? 'xl:grid-cols-[17rem_minmax(0,1fr)] xl:grid-rows-[minmax(0,1fr)]'
: 'xl:grid-cols-[17rem_minmax(0,1fr)_19.25rem] xl:grid-rows-[minmax(0,1fr)_13.5rem]' : 'xl:grid-cols-[17rem_minmax(0,1fr)_19.25rem] xl:grid-rows-[minmax(0,1fr)_13.5rem]'
" "
> >
<ReadingLogVitals <ReadingLogVitals
class="xl:col-start-1 xl:row-start-1" class="xl:col-start-1 xl:row-start-1"
:class="{ 'xl:row-span-2': !resolved }"
:book="book" :book="book"
:stats="stats" :stats="stats"
:loading="loading" :loading="loading"
:hide-add-session="blank" :hide-add-session="blank || !resolved"
@saved="handleVitalsSaved" @saved="handleVitalsSaved"
@add-session="handleOpenAddSession" @add-session="handleOpenAddSession"
/> />
<ReadingLogEmptyStage v-if="blank" class="xl:col-start-2 xl:row-start-1" @add-session="handleOpenAddSession" @record-past="handleRecordPast" /> <div
v-if="!resolved"
class="min-h-80 rounded-xl border border-border bg-card xl:col-start-2 xl:col-span-2 xl:row-start-1 xl:row-span-2 xl:min-h-0"
aria-busy="true"
>
<div v-if="showFirstLoadSkeleton" class="flex flex-col gap-2 px-3 py-3" aria-hidden="true">
<div v-for="row in 6" :key="row" class="h-4 rounded bg-muted animate-shimmer" />
</div>
</div>
<ReadingLogEmptyStage
v-else-if="blank"
class="xl:col-start-2 xl:row-start-1"
@add-session="handleOpenAddSession"
@record-past="handleRecordPast"
/>
<template v-else> <template v-else>
<ReadingLogLedger <ReadingLogLedger
@@ -95,10 +95,14 @@ export function useBookHighlights(bookIdRef: Ref<number>) {
} }
let fetchSeq = 0 let fetchSeq = 0
async function fetchHighlights() { /**
* `silent` refetches without raising `loading`, so a revalidation behind an already-rendered
* stream cannot flip the tab through its empty state on the way back.
*/
async function fetchHighlights(opts?: { silent?: boolean }) {
const bookId = bookIdRef.value const bookId = bookIdRef.value
const seq = ++fetchSeq const seq = ++fetchSeq
loading.value = true if (!opts?.silent) loading.value = true
error.value = null error.value = null
try { try {
const res = await api(`/api/v1/books/${bookId}/annotations?${buildParams(1).toString()}`) const res = await api(`/api/v1/books/${bookId}/annotations?${buildParams(1).toString()}`)
@@ -42,11 +42,15 @@ export function useBookReadingLog(bookIdRef: Ref<number>) {
return params return params
} }
async function fetchSessions(opts?: { append?: boolean }) { /**
* `silent` refetches without raising `loading`, so a revalidation behind already-rendered rows
* cannot dim or skeleton them. Only a load with nothing on screen yet is worth announcing.
*/
async function fetchSessions(opts?: { append?: boolean; silent?: boolean }) {
const bookId = bookIdRef.value const bookId = bookIdRef.value
const append = opts?.append ?? false const append = opts?.append ?? false
if (append) loadingMore.value = true if (append) loadingMore.value = true
else loading.value = true else if (!opts?.silent) loading.value = true
error.value = null error.value = null
try { try {
const res = await api(`/api/v1/books/${bookId}/sessions?${buildParams().toString()}`) const res = await api(`/api/v1/books/${bookId}/sessions?${buildParams().toString()}`)
@@ -127,9 +131,9 @@ export function useBookReadingLog(bookIdRef: Ref<number>) {
await fetchSessions() await fetchSessions()
} }
async function reload() { async function reload(opts?: { silent?: boolean }) {
page.value = 1 page.value = 1
await fetchSessions() await fetchSessions({ silent: opts?.silent })
} }
async function exportAll(): Promise<BookReadingSession[]> { async function exportAll(): Promise<BookReadingSession[]> {
@@ -29,9 +29,10 @@ export function useReadingAttempts(bookIdRef: Ref<number>) {
return new Map(ordered.map((attempt, index) => [attempt.id, index + 1])) return new Map(ordered.map((attempt, index) => [attempt.id, index + 1]))
}) })
async function load() { /** `silent` keeps `loading` down so a revalidation behind rendered rows cannot skeleton them. */
async function load(opts?: { silent?: boolean }) {
const bookId = bookIdRef.value const bookId = bookIdRef.value
loading.value = true if (!opts?.silent) loading.value = true
error.value = null error.value = null
try { try {
const res = await api(`/api/v1/books/${bookId}/reading-attempts?page=1&pageSize=${PAGE_SIZE}`) const res = await api(`/api/v1/books/${bookId}/reading-attempts?page=1&pageSize=${PAGE_SIZE}`)
+44 -29
View File
@@ -1,5 +1,5 @@
<script setup lang="ts"> <script setup lang="ts">
import { computed, defineAsyncComponent, provide, watch } from 'vue' import { computed, defineAsyncComponent, onMounted, provide, watch } from 'vue'
import { useRoute } from 'vue-router' import { useRoute } from 'vue-router'
import { useI18n } from 'vue-i18n' import { useI18n } from 'vue-i18n'
import type { BookDetail, BookMetadataLockField } from '@bookorbit/types' import type { BookDetail, BookMetadataLockField } from '@bookorbit/types'
@@ -20,8 +20,12 @@ import { useCoverTint } from '@/features/book/composables/useCoverTint'
import { useDisplaySettings } from '@/composables/useDisplaySettings' import { useDisplaySettings } from '@/composables/useDisplaySettings'
import EntityNotFound from '@/components/EntityNotFound.vue' import EntityNotFound from '@/components/EntityNotFound.vue'
const ReadingLogTab = defineAsyncComponent(() => import('@/features/book/components/detail/tabs/ReadingLogTab.vue')) const loadReadingLogTab = () => import('@/features/book/components/detail/tabs/ReadingLogTab.vue')
const HighlightsTab = defineAsyncComponent(() => import('@/features/book/components/detail/tabs/HighlightsTab.vue')) const loadHighlightsTab = () => import('@/features/book/components/detail/tabs/HighlightsTab.vue')
const ReadingLogTab = defineAsyncComponent(loadReadingLogTab)
const HighlightsTab = defineAsyncComponent(loadHighlightsTab)
const KEPT_ALIVE_TABS = ['ReadingLogTab', 'HighlightsTab']
const { t } = useI18n() const { t } = useI18n()
const route = useRoute() const route = useRoute()
@@ -97,6 +101,17 @@ onBookProgressChanged((event) => {
watch(bookId, (id) => fetch(id), { immediate: true }) watch(bookId, (id) => fetch(id), { immediate: true })
// The two lazy tabs render nothing at all while their chunk arrives, so a cold click on either
// blanks the pane. Warm both once the page is idle and the first click has a component to mount.
onMounted(() => {
const warm = () => {
void loadReadingLogTab()
void loadHighlightsTab()
}
if (typeof requestIdleCallback === 'function') requestIdleCallback(warm, { timeout: 2000 })
else setTimeout(warm, 500)
})
function onMetadataSaved(updated: BookDetail) { function onMetadataSaved(updated: BookDetail) {
detail.value = updated detail.value = updated
} }
@@ -120,17 +135,24 @@ function onCoverChanged(source: 'extracted' | 'custom' | null) {
<BookDetailLayout :book-id="bookId" :cover-tint="coverTint"> <BookDetailLayout :book-id="bookId" :cover-tint="coverTint">
<Transition name="content" mode="out-in"> <Transition name="content" mode="out-in">
<div v-if="detail" key="detail" class="h-full"> <div v-if="detail" key="detail" class="h-full">
<DetailsTab v-if="tab === 'details'" :book="detail" @saved="onMetadataSaved" @moved="handleMovedToLibrary" /> <!--
<EditMetadataTab Only the two lazy tabs are cached. Both fetch on mount, so without this every return
v-else-if="tab === 'edit' && hasPermission('library_edit_metadata')" replays blank -> skeleton -> content in under 120ms, which reads as a flash rather than
:book="detail" as loading. They revalidate silently on activation instead.
@saved="onMetadataSaved" -->
@locks-changed="onLocksChanged" <KeepAlive :include="KEPT_ALIVE_TABS">
@cover-changed="onCoverChanged" <DetailsTab v-if="tab === 'details'" :book="detail" @saved="onMetadataSaved" @moved="handleMovedToLibrary" />
/> <EditMetadataTab
<FilesTab v-else-if="tab === 'files'" :book="detail" @refetch="fetch(detail.id)" /> v-else-if="tab === 'edit' && hasPermission('library_edit_metadata')"
<ReadingLogTab v-else-if="tab === 'reading-log'" :book="detail" @saved="onMetadataSaved" /> :book="detail"
<HighlightsTab v-else-if="tab === 'highlights'" :book="detail" /> @saved="onMetadataSaved"
@locks-changed="onLocksChanged"
@cover-changed="onCoverChanged"
/>
<FilesTab v-else-if="tab === 'files'" :book="detail" @refetch="fetch(detail.id)" />
<ReadingLogTab v-else-if="tab === 'reading-log'" :book="detail" @saved="onMetadataSaved" />
<HighlightsTab v-else-if="tab === 'highlights'" :book="detail" />
</KeepAlive>
</div> </div>
<div v-else-if="loading" key="loading"> <div v-else-if="loading" key="loading">
@@ -162,26 +184,19 @@ function onCoverChanged(source: 'extracted' | 'custom' | null) {
<div v-else-if="tab === 'files'" class="space-y-3"> <div v-else-if="tab === 'files'" class="space-y-3">
<div v-for="i in 3" :key="i" class="h-16 rounded-md bg-muted animate-shimmer" /> <div v-for="i in 3" :key="i" class="h-16 rounded-md bg-muted animate-shimmer" />
</div> </div>
<!--
These two match the box the tab itself puts up while its own first load runs, so the
handover from "book loading" to "tab loading" changes the fill and never the layout.
-->
<div <div
v-else-if="tab === 'reading-log'" v-else-if="tab === 'reading-log'"
class="flex h-full min-h-0 flex-col gap-4 xl:grid xl:grid-cols-[17rem_minmax(0,1fr)_19.25rem] xl:grid-rows-[minmax(0,1fr)_13.5rem] xl:gap-x-5 xl:gap-y-4" class="flex h-full min-h-0 flex-col gap-4 xl:grid xl:grid-cols-[17rem_minmax(0,1fr)_19.25rem] xl:grid-rows-[minmax(0,1fr)_13.5rem] xl:gap-x-5 xl:gap-y-4"
> >
<div class="h-64 rounded-xl bg-muted animate-shimmer xl:col-start-1 xl:row-start-1 xl:h-auto" /> <div class="h-64 rounded-xl bg-muted animate-shimmer xl:col-start-1 xl:row-start-1 xl:row-span-2 xl:h-auto" />
<div class="h-72 rounded-xl bg-muted animate-shimmer xl:col-start-2 xl:row-start-1 xl:h-auto" /> <div class="min-h-80 rounded-xl bg-muted animate-shimmer xl:col-start-2 xl:col-span-2 xl:row-start-1 xl:row-span-2 xl:min-h-0" />
<div class="flex flex-col gap-4 xl:col-start-3 xl:row-start-1 xl:min-h-0">
<div class="h-40 rounded-xl bg-muted animate-shimmer xl:min-h-0 xl:flex-1" />
<div class="h-[9.375rem] shrink-0 rounded-xl bg-muted animate-shimmer" />
</div>
<div class="h-56 rounded-xl bg-muted animate-shimmer xl:col-span-full xl:row-start-2 xl:h-auto" />
</div> </div>
<div v-else-if="tab === 'highlights'" class="space-y-4"> <div v-else-if="tab === 'highlights'" class="flex h-full min-h-0 flex-col">
<div class="flex gap-2"> <div class="min-h-80 flex-1 rounded-xl bg-muted animate-shimmer xl:min-h-0" />
<div class="h-9 flex-1 rounded-md bg-muted animate-shimmer" />
<div v-for="i in 3" :key="i" class="h-9 w-9 rounded-full bg-muted animate-shimmer" />
</div>
<div class="space-y-3">
<div v-for="i in 3" :key="i" class="h-24 rounded-lg bg-muted animate-shimmer" />
</div>
</div> </div>
</div> </div>
@@ -343,10 +343,12 @@ describe('GoodreadsProvider', () => {
return Promise.resolve({ ok: true, status: 202, text: () => Promise.resolve('<div id="challenge-container"></div>') }); return Promise.resolve({ ok: true, status: 202, text: () => Promise.resolve('<div id="challenge-container"></div>') });
}) as never; }) as never;
const searchPromise = provider.search({ title: 'B' }); // The rejection lands while the timers run, so the assertion has to be attached before then:
// a rejected promise nobody is holding is an unhandled rejection, which fails the whole run.
const rejects = expect(provider.search({ title: 'B' })).rejects.toBeInstanceOf(ProviderThrottleError);
await vi.runAllTimersAsync(); await vi.runAllTimersAsync();
await expect(searchPromise).rejects.toBeInstanceOf(ProviderThrottleError); await rejects;
const detailUrls = vi const detailUrls = vi
.mocked(global.fetch) .mocked(global.fetch)
.mock.calls.map(([url]) => fetchUrl(url)) .mock.calls.map(([url]) => fetchUrl(url))