From 8b9442be1fc4e67bc9e48534c0b751e1dc9d7394 Mon Sep 17 00:00:00 2001 From: neon <18559658+neonsolstice@users.noreply.github.com> Date: Wed, 26 Aug 2026 19:53:36 +0000 Subject: [PATCH] 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. --- client/src/composables/useDeferredLoading.ts | 64 ++++++++++++++++ .../detail/reading-log/ReadingLogVitals.vue | 22 +++++- .../components/detail/tabs/HighlightsTab.vue | 50 ++++++++++++- .../components/detail/tabs/ReadingLogTab.vue | 52 ++++++++++++- .../book/composables/useBookHighlights.ts | 8 +- .../book/composables/useBookReadingLog.ts | 12 ++- .../book/composables/useReadingAttempts.ts | 5 +- client/src/views/BookDetailView.vue | 73 +++++++++++-------- .../goodreads/goodreads.provider.test.ts | 6 +- 9 files changed, 241 insertions(+), 51 deletions(-) create mode 100644 client/src/composables/useDeferredLoading.ts diff --git a/client/src/composables/useDeferredLoading.ts b/client/src/composables/useDeferredLoading.ts new file mode 100644 index 000000000..59ada84bf --- /dev/null +++ b/client/src/composables/useDeferredLoading.ts @@ -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, options?: { showAfterMs?: number; minVisibleMs?: number }): Ref { + const showAfterMs = options?.showAfterMs ?? SHOW_AFTER_MS + const minVisibleMs = options?.minVisibleMs ?? MIN_VISIBLE_MS + + const visible = ref(false) + let showTimer: ReturnType | null = null + let hideTimer: ReturnType | 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 +} diff --git a/client/src/features/book/components/detail/reading-log/ReadingLogVitals.vue b/client/src/features/book/components/detail/reading-log/ReadingLogVitals.vue index b999e56a8..ed8ef6a98 100644 --- a/client/src/features/book/components/detail/reading-log/ReadingLogVitals.vue +++ b/client/src/features/book/components/detail/reading-log/ReadingLogVitals.vue @@ -273,24 +273,38 @@ const formatLine = computed(() => 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(() => [ { key: 'total', label: t('book.detail.readingLog.vitals.totalTime'), - value: formatDuration(props.stats?.totalSeconds ?? 0), + value: statsPending.value ? '-' : formatDuration(props.stats?.totalSeconds ?? 0), 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', label: t('book.detail.readingLog.vitals.avgSession'), - value: formatDuration(props.stats?.avgDurationSeconds ?? 0), + value: statsPending.value ? '-' : formatDuration(props.stats?.avgDurationSeconds ?? 0), withMomentum: false, }, { key: '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, }, { diff --git a/client/src/features/book/components/detail/tabs/HighlightsTab.vue b/client/src/features/book/components/detail/tabs/HighlightsTab.vue index 0ad2f41e2..4da0bf24e 100644 --- a/client/src/features/book/components/detail/tabs/HighlightsTab.vue +++ b/client/src/features/book/components/detail/tabs/HighlightsTab.vue @@ -1,11 +1,12 @@