@@ -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 ( ) ;
0 commit comments