Skip to content

Commit b2c9103

Browse files
Fix external scrRef change-related issues (UNFINISHED)
1 parent d713396 commit b2c9103

9 files changed

Lines changed: 401 additions & 55 deletions

cspell.json

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -49,6 +49,7 @@
4949
"recentering",
5050
"recenters",
5151
"relayout",
52+
"resnap",
5253
"sandboxed",
5354
"scriptio",
5455
"scrollers",

src/__tests__/components/ContinuousView.test.tsx

Lines changed: 65 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -524,6 +524,71 @@ describe('ContinuousView initial render', () => {
524524
const focusedBox = screen.getByText('beginning').closest('[data-phrase-box="true"]');
525525
expect(focusedBox).toHaveAttribute('data-focus-state', 'focused');
526526
});
527+
528+
it('falls back to focusedTokenRef when the lagging displayed ref is from another book', () => {
529+
// During a book change displayFocusedTokenRef lags by one fade, so it briefly names a token from
530+
// the previous book that no longer exists in the new book. The focus must follow the live
531+
// focusedTokenRef (the new book's active verse) rather than collapsing to the book's first phrase.
532+
const book = makeBook();
533+
const { rerender } = render(
534+
<ContinuousView {...requiredProps(book, { focusedTokenRef: 'tok-2' })} />,
535+
withAnalysisStore,
536+
);
537+
538+
// Swap to a different book whose token refs share none of the previous book's. The displayed ref
539+
// ('tok-2') is now absent; focusedTokenRef points at the new book's *second* phrase.
540+
const otherBook: Book = {
541+
id: 'MAT',
542+
bookRef: 'MAT',
543+
textVersion: '1',
544+
segments: [
545+
{
546+
id: 'MAT 1:1',
547+
startRef: { book: 'MAT', chapter: 1, verse: 1 },
548+
endRef: { book: 'MAT', chapter: 1, verse: 1 },
549+
baselineText: 'Alpha',
550+
tokens: [
551+
{
552+
ref: 'mat-tok-0',
553+
surfaceText: 'Alpha',
554+
writingSystem: 'en',
555+
type: 'word',
556+
charStart: 0,
557+
charEnd: 5,
558+
},
559+
],
560+
},
561+
{
562+
id: 'MAT 1:2',
563+
startRef: { book: 'MAT', chapter: 1, verse: 2 },
564+
endRef: { book: 'MAT', chapter: 1, verse: 2 },
565+
baselineText: 'Beta',
566+
tokens: [
567+
{
568+
ref: 'mat-tok-1',
569+
surfaceText: 'Beta',
570+
writingSystem: 'en',
571+
type: 'word',
572+
charStart: 0,
573+
charEnd: 4,
574+
},
575+
],
576+
},
577+
],
578+
};
579+
580+
scrollIntoViewMock.mockClear();
581+
rerender(<ContinuousView {...requiredProps(otherBook, { focusedTokenRef: 'mat-tok-1' })} />);
582+
583+
// The scroll target is resolved through focusPhraseIndex, which falls back to focusedTokenRef
584+
// ('mat-tok-1', the second phrase) rather than collapsing to phrase 0. So the element scrolled
585+
// into view is the one containing "Beta", never "Alpha".
586+
const scrolledTexts = scrollIntoViewMock.mock.contexts.map((el) =>
587+
el instanceof HTMLElement ? el.textContent : undefined,
588+
);
589+
expect(scrolledTexts.some((t) => t?.includes('Beta'))).toBe(true);
590+
expect(scrolledTexts.some((t) => t?.includes('Alpha'))).toBe(false);
591+
});
527592
});
528593

529594
// ---------------------------------------------------------------------------

src/__tests__/components/Interlinearizer.test.tsx

Lines changed: 9 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -654,17 +654,25 @@ describe('Interlinearizer', () => {
654654
).not.toBeInTheDocument();
655655
});
656656

657-
it('snap button calls scrollIntoView on the active segment', () => {
657+
it('snap button fades, recenters, then scrolls the active segment to the top', () => {
658+
jest.useFakeTimers();
658659
renderInterlinearizer({ book: GEN_1_1_BOOK });
659660

660661
act(() => {
661662
screen.getByRole('button', { name: /scroll to active verse/i }).click();
662663
});
663664

665+
// The button always fade-recenters (so a verse outside the window still comes into view), so the
666+
// snap only lands after the fade timeout rebuilds the window behind the curtain.
667+
act(() => {
668+
jest.advanceTimersByTime(RECENTER_FADE_MS);
669+
});
670+
664671
expect(Element.prototype.scrollIntoView).toHaveBeenCalledWith({
665672
behavior: 'auto',
666673
block: 'start',
667674
});
675+
jest.useRealTimers();
668676
});
669677

670678
it('leaves focusedTokenRef undefined when switching off continuousScroll with no strip position and no matching segment', () => {

src/__tests__/components/InterlinearizerLoader.test.tsx

Lines changed: 48 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -237,14 +237,19 @@ jest.mock('../../components/modals/ProjectModals', () => ({
237237
},
238238
}));
239239

240-
/** Returns a `useWebViewScrollGroupScrRef` hook stub fixed to GEN 1:1. */
241-
function makeScrollGroupHook() {
240+
/**
241+
* Returns a `useWebViewScrollGroupScrRef` hook stub fixed to a scripture reference.
242+
*
243+
* @param ref - The reference the stub reports; defaults to GEN 1:1.
244+
* @returns A hook returning `[ref, noop setScrRef, undefined scrollGroupId, noop setter]`.
245+
*/
246+
function makeScrollGroupHook(ref: SerializedVerseRef = defaultScrRef) {
242247
return (): [
243248
SerializedVerseRef,
244249
(r: SerializedVerseRef) => void,
245250
number | undefined,
246251
(id: number | undefined) => void,
247-
] => [defaultScrRef, () => {}, undefined, () => {}];
252+
] => [ref, () => {}, undefined, () => {}];
248253
}
249254

250255
/**
@@ -356,6 +361,46 @@ describe('InterlinearizerLoader', () => {
356361
expect(screen.getByTestId('interlinearizer')).toBeInTheDocument();
357362
});
358363

364+
it('normalizes a chapter-level (verse 0) reference to verse 1 before passing it to Interlinearizer', () => {
365+
render(
366+
<InterlinearizerLoader
367+
projectId={testProjectId}
368+
useWebViewScrollGroupScrRef={makeScrollGroupHook({
369+
book: 'GEN',
370+
chapterNum: 3,
371+
verseNum: 0,
372+
})}
373+
useWebViewState={makeWebViewState()}
374+
/>,
375+
);
376+
377+
expect(capturedInterlinearizerProps?.scrRef).toEqual({
378+
book: 'GEN',
379+
chapterNum: 3,
380+
verseNum: 1,
381+
});
382+
});
383+
384+
it('passes a verse-level reference through to Interlinearizer unchanged', () => {
385+
render(
386+
<InterlinearizerLoader
387+
projectId={testProjectId}
388+
useWebViewScrollGroupScrRef={makeScrollGroupHook({
389+
book: 'GEN',
390+
chapterNum: 3,
391+
verseNum: 4,
392+
})}
393+
useWebViewState={makeWebViewState()}
394+
/>,
395+
);
396+
397+
expect(capturedInterlinearizerProps?.scrRef).toEqual({
398+
book: 'GEN',
399+
chapterNum: 3,
400+
verseNum: 4,
401+
});
402+
});
403+
359404
it('shows Loading when book data has not arrived', () => {
360405
mockBookData({ book: undefined, isLoading: true });
361406
render(

src/__tests__/hooks/useSegmentWindow.test.ts

Lines changed: 76 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -393,14 +393,88 @@ describe('useSegmentWindow', () => {
393393
act(() => rerender({ b: book, ref: { book: 'GEN', chapterNum: 1, verseNum: 50 } }));
394394
act(() => jest.advanceTimersByTime(RECENTER_FADE_MS));
395395

396-
// The synchronous layout-effect snap has fired once; the post-paint re-snap is still pending.
396+
// The synchronous layout-effect snap has fired once; the post-paint re-snap loop is still pending.
397397
expect(scrollIntoView).toHaveBeenCalledTimes(1);
398398

399-
// Flushing the requestAnimationFrame re-snaps against the now-settled layout.
399+
// Flushing the first animation frame re-snaps against the now-painted layout.
400400
act(() => jest.advanceTimersByTime(16));
401401
expect(scrollIntoView).toHaveBeenCalledTimes(2);
402402
});
403403

404+
it('keeps re-snapping across frames until the scroll position settles', () => {
405+
const book = makeBook(60, 0);
406+
const scrollIntoView = jest.fn();
407+
Element.prototype.scrollIntoView = scrollIntoView;
408+
const { container, rerender } = renderSegmentWindow(book, {
409+
book: 'GEN',
410+
chapterNum: 1,
411+
verseNum: 1,
412+
});
413+
414+
const active = document.createElement('div');
415+
active.setAttribute('aria-current', 'true');
416+
container.appendChild(active);
417+
418+
act(() => rerender({ b: book, ref: { book: 'GEN', chapterNum: 1, verseNum: 50 } }));
419+
act(() => jest.advanceTimersByTime(RECENTER_FADE_MS));
420+
// Layout-effect snap: 1.
421+
expect(scrollIntoView).toHaveBeenCalledTimes(1);
422+
423+
// First frame: snaps (2) and records the resulting scrollTop, then schedules another frame
424+
// because the position has not yet been observed twice.
425+
act(() => jest.advanceTimersByTime(16));
426+
expect(scrollIntoView).toHaveBeenCalledTimes(2);
427+
428+
// Second frame: snaps (3); scrollTop is unchanged from the prior frame (jsdom has no layout), so
429+
// the loop sees the position settled and stops scheduling further frames.
430+
act(() => jest.advanceTimersByTime(16));
431+
expect(scrollIntoView).toHaveBeenCalledTimes(3);
432+
433+
// Further frames do not re-snap: the loop has terminated.
434+
act(() => jest.advanceTimersByTime(16 * 5));
435+
expect(scrollIntoView).toHaveBeenCalledTimes(3);
436+
});
437+
438+
it('stops re-snapping at the frame cap when the scroll position never settles', () => {
439+
const book = makeBook(60, 0);
440+
const scrollIntoView = jest.fn();
441+
Element.prototype.scrollIntoView = scrollIntoView;
442+
const { container, rerender } = renderSegmentWindow(book, {
443+
book: 'GEN',
444+
chapterNum: 1,
445+
verseNum: 1,
446+
});
447+
448+
const active = document.createElement('div');
449+
active.setAttribute('aria-current', 'true');
450+
container.appendChild(active);
451+
452+
// Make scrollTop change on every read so the settle check never short-circuits the loop; only
453+
// the frame cap can stop it.
454+
let scrollTop = 0;
455+
Object.defineProperty(container, 'scrollTop', {
456+
configurable: true,
457+
get: () => {
458+
scrollTop += 1;
459+
return scrollTop;
460+
},
461+
set: () => {
462+
// Ignore writes; the getter drives the ever-changing value.
463+
},
464+
});
465+
466+
act(() => rerender({ b: book, ref: { book: 'GEN', chapterNum: 1, verseNum: 50 } }));
467+
act(() => jest.advanceTimersByTime(RECENTER_FADE_MS));
468+
// Layout-effect snap.
469+
expect(scrollIntoView).toHaveBeenCalledTimes(1);
470+
471+
// Drive far more frames than the cap allows; the loop must stop after exactly the capped number
472+
// of re-snaps rather than spinning forever.
473+
act(() => jest.advanceTimersByTime(16 * 50));
474+
// 1 layout-effect snap + the frame-capped re-snaps.
475+
expect(scrollIntoView).toHaveBeenCalledTimes(1 + 20);
476+
});
477+
404478
it('does not re-snap on the initial mount, only after a recenter', () => {
405479
const book = makeBook(60, 0);
406480
const scrollIntoView = jest.fn();

src/components/ContinuousView.tsx

Lines changed: 14 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -231,13 +231,22 @@ export default function ContinuousView({
231231
/**
232232
* Group index of the displayed focused token, or `0` when nothing is focused. Single source of
233233
* truth for scroll position, windowing, arrow disabled state, and per-group focus highlighting.
234+
*
235+
* During a book change `displayFocusedTokenRef` lags the new book by one fade (it only catches up
236+
* when the fade timeout fires), so for a few frames it names a token from the previous book that
237+
* no longer exists in this book's `groupIndexByTokenRef`. Falling straight back to `0` then parks
238+
* the strip on the new book's very first phrase instead of the verse the user navigated to. Fall
239+
* back to the live `focusedTokenRef` first — the parent reseeds it to the new book's active verse
240+
* on the book change — so the transient lands on the intended verse rather than book start.
234241
*/
235242
const focusPhraseIndex = useMemo(() => {
236-
if (displayFocusedTokenRef === undefined) return 0;
237-
const gi = groupIndexByTokenRef.get(displayFocusedTokenRef);
238-
/* v8 ignore next -- gi is always defined when displayFocusedTokenRef is set */
239-
return gi === undefined ? 0 : clampIndex(gi, phraseGroups.length);
240-
}, [displayFocusedTokenRef, groupIndexByTokenRef, phraseGroups.length]);
243+
const resolved =
244+
(displayFocusedTokenRef !== undefined
245+
? groupIndexByTokenRef.get(displayFocusedTokenRef)
246+
: undefined) ??
247+
(focusedTokenRef !== undefined ? groupIndexByTokenRef.get(focusedTokenRef) : undefined);
248+
return resolved === undefined ? 0 : clampIndex(resolved, phraseGroups.length);
249+
}, [displayFocusedTokenRef, focusedTokenRef, groupIndexByTokenRef, phraseGroups.length]);
241250

242251
const [isVisible, setIsVisible] = useState(false);
243252

src/components/Interlinearizer.tsx

Lines changed: 33 additions & 15 deletions
Original file line numberDiff line numberDiff line change
@@ -169,26 +169,36 @@ function InterlinearizerInner({
169169
}, []);
170170

171171
/**
172-
* Scrolls the element marked `aria-current="true"` inside the scroll container into view at the
173-
* top of the list.
172+
* Recenters the segment list on the active verse with the same fade-and-rebuild used for external
173+
* navigation. Used by the LocateFixed button and the continuous-scroll mode switch. Always fades
174+
* (even when the verse is already on screen) so the active verse is guaranteed to land in view —
175+
* a plain `scrollIntoView` of an `aria-current` element silently no-ops when the verse is outside
176+
* the render window, leaving the list parked wherever it was.
177+
*
178+
* `recenterOnActive` is captured via ref so this callback's identity stays stable.
174179
*/
180+
const recenterOnActiveRef = useRef<() => void>(() => undefined);
175181
const snapToActive = useCallback(() => {
176-
const container = scrollContainerRef.current;
177-
const active = container?.querySelector('[aria-current="true"]');
178-
/* v8 ignore next -- active is always found when a verse is rendered; guard for empty lists */
179-
active?.scrollIntoView({ behavior: 'auto', block: 'start' });
182+
recenterOnActiveRef.current();
180183
}, []);
181184

182185
// Scroll-anchored window into the full book's segment list. Spans chapters, grows/culls at the
183186
// scrolled edge, and recenters (with a fade) on the active verse when navigation arrives from
184187
// outside the list.
185-
const { windowSegments, isFaded, displayScrRef, topSentinelRef, bottomSentinelRef } =
186-
useSegmentWindow({
187-
book,
188-
scrRef,
189-
scrollContainerRef,
190-
internalNavRef,
191-
});
188+
const {
189+
windowSegments,
190+
isFaded,
191+
displayScrRef,
192+
topSentinelRef,
193+
bottomSentinelRef,
194+
recenterOnActive,
195+
} = useSegmentWindow({
196+
book,
197+
scrRef,
198+
scrollContainerRef,
199+
internalNavRef,
200+
});
201+
recenterOnActiveRef.current = recenterOnActive;
192202

193203
/** PhraseId currently hovered anywhere in the interlinearizer; shared across all SegmentViews. */
194204
const [hoveredPhraseId, setHoveredPhraseId] = useState<string | undefined>();
@@ -217,8 +227,15 @@ function InterlinearizerInner({
217227
// eslint-disable-next-line react-hooks/exhaustive-deps
218228
}, [isRevert, updatePhrase, setPhraseMode]);
219229

220-
// Snap the segment list to the active verse when switching modes.
230+
// Recenter the segment list on the active verse when switching between continuous and segment
231+
// modes. Skips the initial mount: the window is already built centered on the anchor there, so a
232+
// recenter would needlessly fade. Only an actual mode toggle should fade-and-recenter.
233+
const didMountModeSwitchRef = useRef(false);
221234
useEffect(() => {
235+
if (!didMountModeSwitchRef.current) {
236+
didMountModeSwitchRef.current = true;
237+
return;
238+
}
222239
snapToActive();
223240
// eslint-disable-next-line react-hooks/exhaustive-deps
224241
}, [continuousScroll]);
@@ -332,7 +349,7 @@ function InterlinearizerInner({
332349
<div className="tw:sticky tw:top-0 tw:z-10 tw:flex tw:justify-end tw:pointer-events-none">
333350
<button
334351
aria-label="Scroll to active verse"
335-
className="tw:rounded tw:p-1 tw:text-foreground tw:hover:bg-muted/50 tw:pointer-events-auto"
352+
className="tw:rounded tw:p-1 tw:text-foreground tw:bg-background tw:hover:bg-muted/50 tw:pointer-events-auto"
336353
tabIndex={-1}
337354
onClick={snapToActive}
338355
type="button"
@@ -358,6 +375,7 @@ function InterlinearizerInner({
358375
focusedTokenRef={continuousScroll ? undefined : focusedTokenRef}
359376
hoveredPhraseId={hoveredPhraseId}
360377
isActive={
378+
seg.startRef.book === displayScrRef.book &&
361379
seg.startRef.chapter === displayScrRef.chapterNum &&
362380
seg.startRef.verse === displayScrRef.verseNum
363381
}

0 commit comments

Comments
 (0)