Skip to content

Commit 8b4cc27

Browse files
Treat verse 0 as an ordinary verse; remove liveScrRef stickiness guard
A chapter's pre-verse-1 superscription is a real, focusable verse-0 segment, but `liveScrRef` held a stickiness guard that swallowed any same-chapter `verseNum: 0` reference naming the verse already shown. That guard was added (b9dec18, "fix verse-0 echo nav") before verse 0 was a parsed verse, to absorb the host's spurious post-verse-nav chapter echo. It could not distinguish that echo from a genuine external `<` (previous-verse) from verse 1 — both are same-chapter, verse 0, and markerless on the global selector path — so it also ate the intentional `<`, leaving the extension stuck on verse 1 instead of moving to the superscription. Verified in a live session (probe logging liveScrRef/rawScrRef) that the host no longer emits the spurious echo: exactly one delivery per navigation, never an unsolicited trailing verse 0. With the echo gone the guard defended against nothing, so remove it. Verse 0 now passes through verbatim; the host's `<` from verse 1 lands on the chapter's superscription (the loader resolves verse 0 to the superscription segment, else to verse 1). The internal-nav marker machinery is retained — it still classifies internal/external navigations for the recenter fade. Only its former role inside the verse-0 guard is gone. Tests: collapse the two sticky-behavior tests into one pass-through regression test; flip the mid-reveal fade test to expect the curtain to re-engage for a verse-0 navigation arriving during fade-in (verse 0 is now an ordinary mid-reveal move). Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
1 parent 577781b commit 8b4cc27

4 files changed

Lines changed: 42 additions & 81 deletions

File tree

src/__tests__/components/InterlinearNavContext.test.tsx

Lines changed: 13 additions & 29 deletions
Original file line numberDiff line numberDiff line change
@@ -84,35 +84,17 @@ describe('InterlinearNavContext', () => {
8484
expect(result.current.rawScrRef).toEqual(ref);
8585
});
8686

87-
it('keeps the current verse when the host echoes a verse-0 reference for the chapter already shown', () => {
88-
// After a verse navigation the host re-broadcasts the chapter as a separate verse-0 reference.
89-
// Normalizing that to verse 1 would read as a fresh move off the verse the user is on, so a
90-
// verse-0 echo for the same book+chapter must stay sticky on the current verse.
87+
it('passes a same-chapter verse-0 reference through to liveScrRef (host < from verse 1)', () => {
88+
// Verse 0 is a real, focusable verse (a chapter superscription), so a verse-0 reference for the
89+
// chapter already shown passes through verbatim rather than being held on the current verse. This
90+
// is the host's `<` (previous-verse) from verse 1: it must land on the superscription, which the
91+
// loader resolves from verse 0 (or back to verse 1 when the chapter has no verse-0 segment).
9192
const { result, setRef, rerender } = renderNavMutable({
9293
book: 'GEN',
9394
chapterNum: 3,
94-
verseNum: 7,
95+
verseNum: 1,
9596
});
96-
expect(result.current.liveScrRef).toEqual({ book: 'GEN', chapterNum: 3, verseNum: 7 });
97-
98-
act(() => setRef({ book: 'GEN', chapterNum: 3, verseNum: 0 }));
99-
rerender();
100-
101-
expect(result.current.liveScrRef).toEqual({ book: 'GEN', chapterNum: 3, verseNum: 7 });
102-
});
103-
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'));
97+
expect(result.current.liveScrRef).toEqual({ book: 'GEN', chapterNum: 3, verseNum: 1 });
11698

11799
act(() => setRef({ book: 'GEN', chapterNum: 3, verseNum: 0 }));
118100
rerender();
@@ -542,9 +524,11 @@ describe('InterlinearNavContext', () => {
542524
expect(result.current.fadePhase).toBe('out');
543525
});
544526

545-
it('does not re-engage the curtain for a verse-0 echo during the fade-in', () => {
546-
// The host's chapter re-broadcast (verse 0 for the chapter already shown) is sticky — it
547-
// names the verse currently displayed, so it must not read as a fresh navigation mid-reveal.
527+
it('re-engages the curtain for a verse-0 navigation arriving during the fade-in', () => {
528+
// Verse 0 is an ordinary verse (a chapter superscription), so a verse-0 reference naming a
529+
// different verse than the one shown is a real mid-reveal navigation — e.g. the host's `<` from
530+
// verse 1 landing on the superscription. It re-engages the curtain like any other external move
531+
// arriving while the new book is still fading in, rather than fading the fresh content twice.
548532
const { result, rerender, setRef } = renderNavMutable({
549533
book: 'GEN',
550534
chapterNum: 1,
@@ -559,7 +543,7 @@ describe('InterlinearNavContext', () => {
559543
act(() => setRef({ book: 'ZEP', chapterNum: 3, verseNum: 0 }));
560544
rerender();
561545

562-
expect(result.current.fadePhase).toBe('in');
546+
expect(result.current.fadePhase).toBe('out');
563547
});
564548

565549
it('clears the pending fade-in timer on unmount', () => {

src/__tests__/components/Interlinearizer.test.tsx

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

536536
it('writes a verse-0 reference to the host when a verse-0 token is selected', () => {
537537
// 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.
538+
// 'internal' origin records a nav marker so the segment window skips the recenter fade for our
539+
// own move. Default scrRef is GEN 1:1, so this is a real verse change.
540540
const mockNavigate = jest.fn();
541541
renderInterlinearizer({ book: GEN_SUPERSCRIPTION_BOOK, navigate: mockNavigate });
542542

src/components/InterlinearNavContext.tsx

Lines changed: 22 additions & 45 deletions
Original file line numberDiff line numberDiff line change
@@ -113,20 +113,14 @@ export interface InterlinearNav {
113113
*/
114114
rawScrRef: SerializedVerseRef;
115115
/**
116-
* 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 — 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.
124-
*
125-
* The sticky exception exists because, after a verse navigation, the host re-broadcasts the
126-
* chapter as a separate `verseNum: 0` reference (an echo of the current location, not a real
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).
116+
* The active reference. Equal to `rawScrRef`, with object identity reused across the host's
117+
* value-equal duplicate deliveries so a re-send reads as no change. Verse 0 is treated as an
118+
* ordinary verse: a `verseNum: 0` reference passes through verbatim, so the host's `<`
119+
* (previous-verse) from verse 1 lands on the chapter's superscription. When it names a chapter
120+
* with a verse-0 superscription segment, that segment becomes the active verse. Whether a given
121+
* chapter actually has verse-0 content is unknown here (the book is not loaded at this layer), so
122+
* the loader resolves a verse-0 reference with no matching segment back to the chapter's first
123+
* numbered verse before rendering.
130124
*/
131125
liveScrRef: SerializedVerseRef;
132126
/**
@@ -226,8 +220,10 @@ export function InterlinearNavProvider({
226220
stableRawScrRefRef.current = rawScrRef;
227221

228222
/**
229-
* The last committed {@link liveScrRef}, mirrored so the verse-0 stickiness below can compare the
230-
* incoming `rawScrRef` against the verse currently shown.
223+
* The last committed {@link liveScrRef}, mirrored so the duplicate-delivery guard below can reuse
224+
* the previously committed object identity when the host re-sends a value-equal reference, and so
225+
* the render-phase mid-reveal guard can compare the incoming reference against the verse last
226+
* shown.
231227
*/
232228
const liveScrRefRef = useRef<SerializedVerseRef>(rawScrRef);
233229

@@ -236,40 +232,21 @@ export function InterlinearNavProvider({
236232
* `Date.now()` stamp. `navigate(ref, 'internal')` records `verseKey(ref)`; `consumeInternalNav`
237233
* removes it on match. Keyed (not a single value) so that rapid successive clicks both stay
238234
* 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).
235+
* ({@link INTERNAL_NAV_TTL_MS} — see its doc for why stranded markers must expire), honored by
236+
* both readers: `consumeInternalNav` (which also evicts expired markers) and the render-phase
237+
* mid-reveal guard (a pure read — no eviction during render).
243238
*/
244239
const pendingInternalNavRef = useRef<Map<string, number>>(new Map());
245240

246-
// After a verse navigation the host re-broadcasts the *chapter* to the scroll group as a separate
247-
// `verseNum: 0` reference (an echo of the current location, not a real move). Treating that as a
248-
// jump to verse 0 would yank the views off the verse the user is actually on, so a verse-0
249-
// reference that names the book+chapter already shown is held sticky: keep the current `liveScrRef`
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.
241+
// Verse 0 is a real, focusable verse (a chapter's pre-verse-1 superscription content; see
242+
// `usjBookExtractor`), so a `verseNum: 0` reference is passed through verbatim like any other verse
243+
// — including the host's `<` (previous-verse) from verse 1, which lands on the chapter's
244+
// superscription. When the chapter has no verse-0 segment, the loader resolves verse 0 to the
245+
// chapter's first numbered verse before rendering. The duplicate-delivery guard reuses the
246+
// previously committed object when a re-send is value-equal, so a duplicate never reads as a fresh
247+
// navigation.
263248
const liveScrRef = useMemo(() => {
264249
const prev = liveScrRefRef.current;
265-
if (
266-
rawScrRef.verseNum === 0 &&
267-
rawScrRef.book === prev.book &&
268-
rawScrRef.chapterNum === prev.chapterNum &&
269-
!isInternalNavMarkerFresh(pendingInternalNavRef.current.get(verseKey(rawScrRef)))
270-
) {
271-
return prev;
272-
}
273250
return areScrRefsEqual(rawScrRef, prev) ? prev : rawScrRef;
274251
}, [rawScrRef]);
275252
/**

src/components/Interlinearizer.tsx

Lines changed: 5 additions & 5 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-
* 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`.)
215+
* A verse-0 segment (a chapter superscription) navigates like any other verse: the `'internal'`
216+
* origin records a nav marker so the segment window recognizes the host's echo as our own move
217+
* and skips the recenter fade (the target is already on screen).
218218
*
219219
* @param tokenRef - The word-token ref to focus.
220220
*/
@@ -237,8 +237,8 @@ function InterlinearizerInner({
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
239239
* 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`).
240+
* superscription) writes like any other verse — the `'internal'` origin records a nav marker so
241+
* the segment window skips the recenter fade for our own move.
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.

0 commit comments

Comments
 (0)