Skip to content

Commit 18beaa3

Browse files
Sync verse-0 (superscription) selection to the global scroll-group ref
1 parent d13523b commit 18beaa3

6 files changed

Lines changed: 79 additions & 43 deletions

File tree

src/__tests__/components/InterlinearNavContext.test.tsx

Lines changed: 19 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -101,6 +101,25 @@ describe('InterlinearNavContext', () => {
101101
expect(result.current.liveScrRef).toEqual({ book: 'GEN', chapterNum: 3, verseNum: 7 });
102102
});
103103

104+
it('passes a same-chapter verse-0 echo through when it matches a fresh internal-nav marker', () => {
105+
// Selecting a verse-0 superscription navigates the host to verse 0 of the chapter already shown,
106+
// so the host's echo is shaped exactly like the spurious post-nav chapter echo. A fresh
107+
// internal-nav marker for that verse-0 key marks it as our own deliberate move, so the stickiness
108+
// exception lets it through to the superscription rather than holding the prior verse.
109+
const { result, setRef, rerender } = renderNavMutable({
110+
book: 'GEN',
111+
chapterNum: 3,
112+
verseNum: 7,
113+
});
114+
115+
act(() => result.current.navigate({ book: 'GEN', chapterNum: 3, verseNum: 0 }, 'internal'));
116+
117+
act(() => setRef({ book: 'GEN', chapterNum: 3, verseNum: 0 }));
118+
rerender();
119+
120+
expect(result.current.liveScrRef).toEqual({ book: 'GEN', chapterNum: 3, verseNum: 0 });
121+
});
122+
104123
it('passes a verse-0 reference for a different chapter through as a real chapter jump', () => {
105124
// A verse-0 reference for a chapter other than the one shown is a genuine chapter navigation, not
106125
// an echo, so it is honored as verse 0 (the loader maps it to verse 1 if that chapter has no

src/__tests__/components/Interlinearizer.test.tsx

Lines changed: 7 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -533,9 +533,10 @@ describe('Interlinearizer', () => {
533533
expect(mockNavigate).toHaveBeenCalledWith({ book: 'GEN', chapterNum: 1, verseNum: 2 });
534534
});
535535

536-
it('focuses a verse-0 token on select without writing a verse-0 reference to the host', () => {
537-
// Verse 0 (a superscription) cannot be expressed as a host scroll-group reference, so selecting
538-
// one of its tokens must focus it locally without calling navigate.
536+
it('writes a verse-0 reference to the host when a verse-0 token is selected', () => {
537+
// Selecting a superscription token navigates the host to verse 0 like any other verse; the
538+
// internal-nav marker keeps the host's chapter echo from bouncing the view (the stickiness
539+
// exception in InterlinearNavContext). Default scrRef is GEN 1:1, so this is a real verse change.
539540
const mockNavigate = jest.fn();
540541
renderInterlinearizer({ book: GEN_SUPERSCRIPTION_BOOK, navigate: mockNavigate });
541542

@@ -546,10 +547,10 @@ describe('Interlinearizer', () => {
546547
);
547548
});
548549

549-
expect(mockNavigate).not.toHaveBeenCalled();
550+
expect(mockNavigate).toHaveBeenCalledWith({ book: 'GEN', chapterNum: 1, verseNum: 0 });
550551
});
551552

552-
it('focuses a verse-0 token from the strip without writing a verse-0 reference to the host', () => {
553+
it('writes a verse-0 reference to the host when a verse-0 token is focused from the strip', () => {
553554
const mockNavigate = jest.fn();
554555
renderInterlinearizer({
555556
book: GEN_SUPERSCRIPTION_BOOK,
@@ -565,7 +566,7 @@ describe('Interlinearizer', () => {
565566
onFocusedTokenRefChange('GEN 1:0:0');
566567
});
567568

568-
expect(mockNavigate).not.toHaveBeenCalled();
569+
expect(mockNavigate).toHaveBeenCalledWith({ book: 'GEN', chapterNum: 1, verseNum: 0 });
569570
expect(capturedContinuousViewProps.focusedTokenRef).toBe('GEN 1:0:0');
570571
});
571572

src/__tests__/components/SegmentView.test.tsx

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -329,8 +329,8 @@ describe('SegmentView', () => {
329329

330330
await userEvent.click(screen.getByTestId('segment-container'));
331331

332-
// Passes the first word token so the segment gains focus (and the active highlight) on click
333-
// the path that lets a verse-0 superscription become active without a host write.
332+
// Passes the first word token so the segment gains focus (and the active highlight) on click,
333+
// letting the parent both highlight the segment and navigate to its verse.
334334
expect(handleSelect).toHaveBeenCalledTimes(1);
335335
expect(handleSelect).toHaveBeenCalledWith({ book: 'GEN', chapter: 1, verse: 1 }, 'tok-0');
336336
});

src/components/InterlinearNavContext.tsx

Lines changed: 37 additions & 22 deletions
Original file line numberDiff line numberDiff line change
@@ -114,15 +114,19 @@ export interface InterlinearNav {
114114
rawScrRef: SerializedVerseRef;
115115
/**
116116
* The active reference, equal to `rawScrRef` except that a verse-0 reference naming the chapter
117-
* already shown is held sticky on the current verse. A verse-0 reference is otherwise passed
118-
* through verbatim: when it names a chapter with a verse-0 superscription segment, that segment
119-
* becomes the active verse. Whether a given chapter actually has verse-0 content is unknown here
120-
* (the book is not loaded at this layer), so the loader resolves a verse-0 reference with no
121-
* matching segment back to the chapter's first numbered verse before rendering.
117+
* already shown is held sticky on the current verse — unless it matches a fresh internal-nav
118+
* marker (the extension's own move to that chapter's superscription), which passes through. A
119+
* verse-0 reference is otherwise passed through verbatim: when it names a chapter with a verse-0
120+
* superscription segment, that segment becomes the active verse. Whether a given chapter actually
121+
* has verse-0 content is unknown here (the book is not loaded at this layer), so the loader
122+
* resolves a verse-0 reference with no matching segment back to the chapter's first numbered
123+
* verse before rendering.
122124
*
123125
* The sticky exception exists because, after a verse navigation, the host re-broadcasts the
124126
* chapter as a separate `verseNum: 0` reference (an echo of the current location, not a real
125-
* move); treating that as a jump to verse 0 would yank the view off the verse the user is on.
127+
* move); treating that as a jump to verse 0 would yank the view off the verse the user is on. The
128+
* marker carve-out distinguishes that spurious echo from a deliberate verse-0 navigation the
129+
* extension itself just made (which is shaped identically).
126130
*/
127131
liveScrRef: SerializedVerseRef;
128132
/**
@@ -227,20 +231,42 @@ export function InterlinearNavProvider({
227231
*/
228232
const liveScrRefRef = useRef<SerializedVerseRef>(rawScrRef);
229233

234+
/**
235+
* Verse keys of internal navigations still awaiting their host round-trip, each mapped to its
236+
* `Date.now()` stamp. `navigate(ref, 'internal')` records `verseKey(ref)`; `consumeInternalNav`
237+
* removes it on match. Keyed (not a single value) so that rapid successive clicks both stay
238+
* pending and neither host delivery is misread as external. The stamp gives each marker a TTL
239+
* ({@link INTERNAL_NAV_TTL_MS} — see its doc for why stranded markers must expire), honored by ALL
240+
* readers: `consumeInternalNav` (which also evicts expired markers), the verse-0 stickiness
241+
* exception below, and the render-phase mid-reveal guard (both pure reads — no eviction during
242+
* render).
243+
*/
244+
const pendingInternalNavRef = useRef<Map<string, number>>(new Map());
245+
230246
// After a verse navigation the host re-broadcasts the *chapter* to the scroll group as a separate
231247
// `verseNum: 0` reference (an echo of the current location, not a real move). Treating that as a
232248
// jump to verse 0 would yank the views off the verse the user is actually on, so a verse-0
233249
// reference that names the book+chapter already shown is held sticky: keep the current `liveScrRef`
234-
// (its real verse). Every other reference — including a verse-0 reference for a *different* chapter
235-
// (a genuine chapter jump, which the loader resolves to the verse-0 superscription when one exists,
236-
// else to verse 1) — passes through verbatim. The duplicate-delivery guard reuses the previously
237-
// committed object when a re-send is value-equal, so a duplicate never reads as a fresh navigation.
250+
// (its real verse).
251+
//
252+
// The exception is a verse-0 reference the extension itself just navigated to — selecting a
253+
// chapter's verse-0 superscription segment writes `verseNum: 0` for the chapter already shown,
254+
// which is shaped exactly like the spurious echo. A fresh internal-nav marker for that verse-0 key
255+
// distinguishes the two: when one exists, this is our own deliberate move to the superscription, so
256+
// it passes through (and `consumeInternalNav` clears the marker downstream). A pure read here — no
257+
// eviction during render, matching the render-phase mid-reveal guard.
258+
//
259+
// Every other reference — including a verse-0 reference for a *different* chapter (a genuine chapter
260+
// jump, which the loader resolves to the verse-0 superscription when one exists, else to verse 1) —
261+
// passes through verbatim. The duplicate-delivery guard reuses the previously committed object when
262+
// a re-send is value-equal, so a duplicate never reads as a fresh navigation.
238263
const liveScrRef = useMemo(() => {
239264
const prev = liveScrRefRef.current;
240265
if (
241266
rawScrRef.verseNum === 0 &&
242267
rawScrRef.book === prev.book &&
243-
rawScrRef.chapterNum === prev.chapterNum
268+
rawScrRef.chapterNum === prev.chapterNum &&
269+
!isInternalNavMarkerFresh(pendingInternalNavRef.current.get(verseKey(rawScrRef)))
244270
) {
245271
return prev;
246272
}
@@ -254,17 +280,6 @@ export function InterlinearNavProvider({
254280
const prevLiveScrRef = liveScrRefRef.current;
255281
liveScrRefRef.current = liveScrRef;
256282

257-
/**
258-
* Verse keys of internal navigations still awaiting their host round-trip, each mapped to its
259-
* `Date.now()` stamp. `navigate(ref, 'internal')` records `verseKey(ref)`; `consumeInternalNav`
260-
* removes it on match. Keyed (not a single value) so that rapid successive clicks both stay
261-
* pending and neither host delivery is misread as external. The stamp gives each marker a TTL
262-
* ({@link INTERNAL_NAV_TTL_MS} — see its doc for why stranded markers must expire), honored by
263-
* BOTH readers: `consumeInternalNav` (which also evicts expired markers) and the render-phase
264-
* mid-reveal guard (a pure read — no eviction during render).
265-
*/
266-
const pendingInternalNavRef = useRef<Map<string, number>>(new Map());
267-
268283
const navigate = useCallback(
269284
(newScrRef: SerializedVerseRef, origin: NavOrigin = 'external') => {
270285
if (origin === 'internal') pendingInternalNavRef.current.set(verseKey(newScrRef), Date.now());

src/components/Interlinearizer.tsx

Lines changed: 8 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -212,9 +212,9 @@ function InterlinearizerInner({
212212
* reference. (The loader's `viewScrRef` freeze normally keeps the two in sync, so this guards a
213213
* transient.)
214214
*
215-
* Also never navigates to a verse-0 segment (a chapter superscription): the host scroll-group
216-
* reference cannot hold verse 0 (it echoes the chapter back), so the token is focused locally
217-
* without writing a reference the host would bounce.
215+
* A verse-0 segment (a chapter superscription) navigates like any other: the write records an
216+
* internal-nav marker so the host's chapter echo is recognized as our own move rather than
217+
* bouncing the view back. (See the verse-0 stickiness exception in `InterlinearNavContext`.)
218218
*
219219
* @param tokenRef - The word-token ref to focus.
220220
*/
@@ -227,7 +227,7 @@ function InterlinearizerInner({
227227
if (!seg) return;
228228
const { current } = scrRefRef;
229229
if (seg.startRef.book !== current.book) return;
230-
if (seg.startRef.verse === 0 || isSameVerse(seg.startRef, current)) return;
230+
if (isSameVerse(seg.startRef, current)) return;
231231
navigate(toSerializedVerseRef(seg.startRef), 'internal');
232232
},
233233
[segmentById, tokenSegmentMap, navigate, scrRefRef],
@@ -236,17 +236,17 @@ function InterlinearizerInner({
236236
/**
237237
* Updates the active scripture reference (when the verse actually changed) and, when a specific
238238
* token was clicked, focuses that token. Skips the write to PAPI when the clicked verse matches
239-
* the current one, avoiding a gratuitous echo round-trip. Also skips the write for a verse-0
240-
* segment (a chapter superscription) — the host scroll-group reference cannot hold verse 0 — so
241-
* the superscription's tokens are still focusable even though it can't become the active verse.
239+
* the current one, avoiding a gratuitous echo round-trip. A verse-0 segment (a chapter
240+
* superscription) writes like any other verse — the internal-nav marker keeps the host's chapter
241+
* echo from bouncing the view (see the verse-0 stickiness exception in `InterlinearNavContext`).
242242
*
243243
* @param ref - The verse coordinate that was selected.
244244
* @param tokenRef - The token that was clicked; omitted when the whole segment was selected.
245245
*/
246246
const handleSegmentSelect = useCallback(
247247
(ref: ScriptureRef, tokenRef?: string) => {
248248
const { current } = scrRefRef;
249-
if (ref.verse !== 0 && !isSameVerse(ref, current)) {
249+
if (!isSameVerse(ref, current)) {
250250
navigate(toSerializedVerseRef(ref), 'internal');
251251
}
252252
if (tokenRef) setFocusedTokenRef(tokenRef);

src/components/SegmentListView.tsx

Lines changed: 6 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -181,11 +181,12 @@ export default function SegmentListView({
181181
recenterOnActive();
182182
}, [continuousScroll, recenterOnActive]);
183183

184-
// Segment that wears the active highlight. It follows the focused token's segment so a verse-0
185-
// superscription — which cannot become the host's active verse — still highlights when one of its
186-
// tokens is focused. Normal navigation keeps the focused token inside the active verse, so this
187-
// resolves to the same segment as the `displayScrRef` verse; it only diverges for a verse-0 focus.
188-
// Falls back to the active verse when nothing is focused (e.g. the active verse has no word token).
184+
// Segment that wears the active highlight. It follows the focused token's segment so the highlight
185+
// lands on the segment whose token is focused — including a verse-0 superscription. Normal
186+
// navigation keeps the focused token inside the active verse, so this resolves to the same segment
187+
// as the `displayScrRef` verse; it can diverge briefly when a focus move and the host echo it
188+
// triggers are not yet reconciled. Falls back to the active verse when nothing is focused (e.g. the
189+
// active verse has no word token).
189190
const activeSegmentId = displayFocusedTokenRef
190191
? tokenSegmentMap.get(displayFocusedTokenRef)
191192
: undefined;

0 commit comments

Comments
 (0)