Skip to content

Commit dd83b33

Browse files
cameronapakclaude
andauthored
YPE-2776 - feat(ui): show loading overlay when changing chapters in BibleReader (#259)
* YPE-2776 - feat(ui): show loading overlay when changing chapters in BibleReader When changing chapters, BibleReader now keeps the previous chapter's text mounted, dims it to 40% opacity, and floats a spinner over it after a short delay — instead of pulsing stale text (confusingly reads as real content) or flashing a blank spinner. Fast/cached switches stay instant since the dim and spinner are gated behind a ~250ms delay. - Lift usePassage into BibleReader.Content and pass passageState down so the reader owns the loading treatment without touching BibleTextView. On refetch it passes loading:false to suppress BibleTextView's pulse; first load keeps BibleTextView's own centered spinner. - Reset scroll to top on book/chapter change (instant); version-only swaps keep scroll position. - Single role=status live region for the overlay; LoaderIcon is decorative. - Hoist useDelayedLoading out of bible-card.tsx into a shared lib util. - Add deterministic unit test for the delay gate + an integration story asserting stale text persistence, delayed dim+spinner, recovery, and scroll reset. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> * fix(ui): address Greptile review on chapter-change loading overlay - Center the loading spinner with sticky top-[50vh] (viewport-relative) instead of top-1/2, which resolved to 50% of the tall passage container and could strand the spinner off-screen when scrolled (e.g. on version changes where scroll position is preserved). - Drop redundant aria-live="polite"; role="status" already implies it. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> * chore(ui): remove AI-generated comment slop from chapter loading overlay --------- Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>
1 parent 158ade2 commit dd83b33

6 files changed

Lines changed: 227 additions & 28 deletions

File tree

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
1+
---
2+
'@youversion/platform-react-ui': patch
3+
---
4+
5+
BibleReader now keeps the previous chapter's text on screen while the next chapter loads, dimming it and floating a spinner over it (after a short delay) instead of pulsing stale text or flashing a blank spinner. Fast/cached chapter switches stay instant, the scroll position resets to the top on chapter change, and the `useDelayedLoading` helper is shared with BibleCard. No changes to BibleTextView.

packages/ui/src/components/bible-card.tsx

Lines changed: 1 addition & 17 deletions
Original file line numberDiff line numberDiff line change
@@ -6,28 +6,12 @@ import { BibleTextView, type FootnoteData } from './verse';
66
import { BibleAppLogoLockup } from './bible-app-logo-lockup';
77
import { BibleVersionPicker, type BibleVersionPickerPressData } from './bible-version-picker';
88
import { Button } from './ui/button';
9-
import { useEffect, useState } from 'react';
109
import { useControllableState } from '@radix-ui/react-use-controllable-state';
1110
import { SOURCE_SERIF_FONT } from '@/lib/verse-html-utils';
11+
import { useDelayedLoading } from '@/lib/use-delayed-loading';
1212
import { LoaderIcon } from './icons/loader';
1313
import { AnimatedHeight } from './animated-height';
1414

15-
function useDelayedLoading(loading: boolean, delay = 250): boolean {
16-
const [showSpinner, setShowSpinner] = useState(false);
17-
18-
useEffect(() => {
19-
if (!loading) {
20-
setShowSpinner(false);
21-
return;
22-
}
23-
24-
const timer = setTimeout(() => setShowSpinner(true), delay);
25-
return () => clearTimeout(timer);
26-
}, [loading, delay]);
27-
28-
return showSpinner;
29-
}
30-
3115
type PassageResult = ReturnType<typeof usePassage>;
3216
type VersionResult = ReturnType<typeof useVersion>;
3317

packages/ui/src/components/bible-reader.stories.tsx

Lines changed: 76 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,7 @@ import { BibleReader } from './bible-reader';
55
import { setupAuthenticatedUser } from '../test/utils';
66
import { INTER_FONT, SOURCE_SERIF_FONT } from '@/lib/verse-html-utils';
77
import mockBibles from '../test/mock-data/bibles.json';
8+
import { globalHandlers } from '../test/mocks/handlers';
89

910
let signInMock: ReturnType<typeof fn>;
1011

@@ -794,3 +795,78 @@ export const JoshuaIntroChapter: Story = {
794795
});
795796
},
796797
};
798+
799+
export const ChapterChangeLoadingOverlay: Story = {
800+
tags: ['integration'],
801+
args: {
802+
defaultVersionId: 111,
803+
defaultBook: 'JHN',
804+
defaultChapter: '1',
805+
},
806+
parameters: {
807+
msw: {
808+
handlers: [
809+
http.get('*/v1/bibles/111/passages/:usfm', async ({ params }) => {
810+
await delay(800);
811+
const usfm = params.usfm as string;
812+
return HttpResponse.json({
813+
id: usfm,
814+
content: `<div class="p"><span class="verse">Passage text for ${usfm}.</span></div>`,
815+
reference: usfm,
816+
});
817+
}),
818+
...globalHandlers,
819+
],
820+
},
821+
},
822+
render: (args) => (
823+
<div className="yv:h-screen yv:bg-background">
824+
<BibleReader.Root {...args}>
825+
<BibleReader.Content />
826+
<BibleReader.Toolbar />
827+
</BibleReader.Root>
828+
</div>
829+
),
830+
play: async ({ canvasElement }) => {
831+
await waitFor(
832+
async () => {
833+
const renderer = canvasElement.querySelector('[data-slot="yv-bible-renderer"]');
834+
await expect(renderer?.textContent).toContain('JHN.1');
835+
},
836+
{ timeout: 5000 },
837+
);
838+
839+
const nextButton = screen.getByRole('button', { name: /next chapter/i });
840+
await userEvent.click(nextButton);
841+
842+
const rendererAfterClick = canvasElement.querySelector('[data-slot="yv-bible-renderer"]');
843+
await expect(rendererAfterClick?.textContent).toContain('JHN.1');
844+
845+
await waitFor(
846+
async () => {
847+
const overlay = canvasElement.querySelector('[aria-label="Loading passage"]');
848+
await expect(overlay).toBeInTheDocument();
849+
await expect(overlay).toHaveAttribute('role', 'status');
850+
await expect(canvasElement.querySelector('[class*="opacity-40"]')).toBeInTheDocument();
851+
const renderer = canvasElement.querySelector('[data-slot="yv-bible-renderer"]');
852+
await expect(renderer?.textContent).toContain('JHN.1');
853+
},
854+
{ timeout: 2000 },
855+
);
856+
857+
await waitFor(
858+
async () => {
859+
const renderer = canvasElement.querySelector('[data-slot="yv-bible-renderer"]');
860+
await expect(renderer?.textContent).toContain('JHN.2');
861+
await expect(
862+
canvasElement.querySelector('[aria-label="Loading passage"]'),
863+
).not.toBeInTheDocument();
864+
await expect(canvasElement.querySelector('[class*="opacity-40"]')).not.toBeInTheDocument();
865+
},
866+
{ timeout: 5000 },
867+
);
868+
869+
const scroller = canvasElement.querySelector('main');
870+
await expect(scroller?.scrollTop).toBe(0);
871+
},
872+
};

packages/ui/src/components/bible-reader.tsx

Lines changed: 69 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,7 @@ import i18n from '@/i18n';
55
import { useControllableState } from '@radix-ui/react-use-controllable-state';
66
import {
77
useBooks,
8+
usePassage,
89
useTheme,
910
useVersion,
1011
useYVAuth,
@@ -22,6 +23,7 @@ import {
2223
type ReactElement,
2324
} from 'react';
2425
import { cn } from '@/lib/utils';
26+
import { useDelayedLoading } from '@/lib/use-delayed-loading';
2527
import { DEFAULT_LICENSE_FREE_BIBLE_VERSION, getAdjacentChapter } from '@youversion/platform-core';
2628
import { BibleChapterPicker, type BibleChapterPickerPressData } from './bible-chapter-picker';
2729
import { BibleVersionPicker, type BibleVersionPickerPressData } from './bible-version-picker';
@@ -349,13 +351,39 @@ function Content() {
349351
return !inChapters && !isIntro;
350352
}, [bookData, chapter]);
351353

354+
// Own the passage fetch here (instead of BibleTextView) to control the loading
355+
// treatment. Args mirror BibleTextView's internal fetch so the cache key matches.
356+
const {
357+
passage,
358+
loading: passageLoading,
359+
error: passageError,
360+
} = usePassage({
361+
versionId,
362+
usfm: usfmReference,
363+
include_headings: true,
364+
include_notes: true,
365+
options: { enabled: !chapterUnavailable },
366+
});
367+
368+
const isRefetching = !chapterUnavailable && passageLoading && passage !== null;
369+
const showLoadingOverlay = useDelayedLoading(isRefetching);
370+
371+
// Version-only changes intentionally preserve scroll position.
372+
const scrollContainerRef = useRef<HTMLElement>(null);
373+
useEffect(() => {
374+
scrollContainerRef.current?.scrollTo({ top: 0 });
375+
}, [book, chapter]);
376+
352377
let chapterLabel: string = bookData?.chapters?.find((ch) => ch.id === chapter)?.title || chapter;
353378
if (bookData?.intro && chapter === bookData?.intro.id) {
354379
chapterLabel = bookData.intro.title;
355380
}
356381

357382
return (
358-
<main className="yv:*:max-w-lg yv:flex yv:flex-col yv:items-center yv:gap-6 yv:overflow-y-auto yv:px-6 yv:max-sm:px-4 yv:py-12 yv:h-full">
383+
<main
384+
ref={scrollContainerRef}
385+
className="yv:*:max-w-lg yv:flex yv:flex-col yv:items-center yv:gap-6 yv:overflow-y-auto yv:px-6 yv:max-sm:px-4 yv:py-12 yv:h-full"
386+
>
359387
<h1 className="yv:flex yv:gap-2 yv:flex-col yv:justify-center yv:items-center yv:text-muted-foreground yv:font-medium">
360388
<span
361389
className={cn(
@@ -376,16 +404,46 @@ function Content() {
376404
{t('chapterUnavailable')}
377405
</p>
378406
) : (
379-
<BibleTextView
380-
reference={usfmReference}
381-
versionId={versionId}
382-
fontFamily={currentFontFamily}
383-
fontSize={currentFontSize}
384-
lineHeight={lineHeight}
385-
showVerseNumbers={showVerseNumbers}
386-
theme={background}
387-
onFootnotePress={onFootnotePress}
388-
/>
407+
<div className="yv:relative yv:w-full">
408+
<div
409+
className={cn(
410+
'yv:transition-opacity yv:duration-150 yv:motion-reduce:transition-none',
411+
showLoadingOverlay ? 'yv:opacity-40' : 'yv:opacity-100',
412+
)}
413+
>
414+
<BibleTextView
415+
reference={usfmReference}
416+
versionId={versionId}
417+
fontFamily={currentFontFamily}
418+
fontSize={currentFontSize}
419+
lineHeight={lineHeight}
420+
showVerseNumbers={showVerseNumbers}
421+
theme={background}
422+
onFootnotePress={onFootnotePress}
423+
passageState={{
424+
passage,
425+
loading: isRefetching ? false : passageLoading,
426+
error: passageError,
427+
}}
428+
/>
429+
</div>
430+
431+
{showLoadingOverlay ? (
432+
<div
433+
role="status"
434+
aria-label={t('loadingPassageAriaLabel')}
435+
className="yv:pointer-events-none yv:absolute yv:inset-0"
436+
>
437+
{/* top-[50vh] (viewport-relative) keeps the spinner centered in the scrollport.
438+
top-1/2 would resolve to 50% of the tall passage container and strand the
439+
spinner off-screen when scrolled (e.g. on version changes). */}
440+
<LoaderIcon
441+
className="yv:sticky yv:top-[50vh] yv:mx-auto yv:block yv:size-6 yv:-translate-y-1/2 yv:animate-spin yv:text-muted-foreground"
442+
aria-hidden="true"
443+
/>
444+
</div>
445+
) : null}
446+
</div>
389447
)}
390448

391449
{version?.copyright && (
Lines changed: 59 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,59 @@
1+
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
2+
import { act, renderHook } from '@testing-library/react';
3+
import { useDelayedLoading } from './use-delayed-loading';
4+
5+
describe('useDelayedLoading', () => {
6+
beforeEach(() => {
7+
vi.useFakeTimers();
8+
});
9+
10+
afterEach(() => {
11+
vi.useRealTimers();
12+
});
13+
14+
it('stays false while not loading', () => {
15+
const { result } = renderHook(() => useDelayedLoading(false));
16+
expect(result.current).toBe(false);
17+
});
18+
19+
it('does not surface a fast load that resolves before the delay', () => {
20+
const { result, rerender } = renderHook(({ loading }) => useDelayedLoading(loading, 250), {
21+
initialProps: { loading: true },
22+
});
23+
24+
act(() => {
25+
vi.advanceTimersByTime(200);
26+
});
27+
expect(result.current).toBe(false);
28+
29+
rerender({ loading: false });
30+
act(() => {
31+
vi.advanceTimersByTime(500);
32+
});
33+
expect(result.current).toBe(false);
34+
});
35+
36+
it('surfaces a slow load once the delay elapses', () => {
37+
const { result } = renderHook(() => useDelayedLoading(true, 250));
38+
39+
expect(result.current).toBe(false);
40+
act(() => {
41+
vi.advanceTimersByTime(250);
42+
});
43+
expect(result.current).toBe(true);
44+
});
45+
46+
it('resets to false as soon as loading clears', () => {
47+
const { result, rerender } = renderHook(({ loading }) => useDelayedLoading(loading, 250), {
48+
initialProps: { loading: true },
49+
});
50+
51+
act(() => {
52+
vi.advanceTimersByTime(250);
53+
});
54+
expect(result.current).toBe(true);
55+
56+
rerender({ loading: false });
57+
expect(result.current).toBe(false);
58+
});
59+
});
Lines changed: 17 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,17 @@
1+
import { useEffect, useState } from 'react';
2+
3+
export function useDelayedLoading(loading: boolean, delay = 250): boolean {
4+
const [showSpinner, setShowSpinner] = useState(false);
5+
6+
useEffect(() => {
7+
if (!loading) {
8+
setShowSpinner(false);
9+
return;
10+
}
11+
12+
const timer = setTimeout(() => setShowSpinner(true), delay);
13+
return () => clearTimeout(timer);
14+
}, [loading, delay]);
15+
16+
return showSpinner;
17+
}

0 commit comments

Comments
 (0)