@@ -43,6 +43,10 @@ const pendingMarkdown = '[data-component="markdown"]:not([data-markdown-ready])'
4343// exactly to the end, while a one-pixel nudge upward is a deliberate move away from it.
4444const endEpsilon = 0.5
4545
46+ // Chrome rounds scrollHeight and clientHeight to whole pixels but scrolls in zoomed ones, so under page zoom (down to
47+ // 25%) the furthest offset it reaches can sit up to 4px short of scrollHeight - clientHeight.
48+ const zoomSlack = 4
49+
4650const upwardKeys = new Set ( [ "up" , "page-up" , "home" ] )
4751
4852const cache = new Map <
@@ -216,6 +220,8 @@ export function createTimelineVirtualizer(input: Input) {
216220 } ,
217221 )
218222 let scrollTop = 0
223+ // A scroll arrived within the zoom slack of the end and may have reached it.
224+ let endPending = false
219225 let reportOffset : ( ( offset : number , scrolling : boolean ) => void ) | undefined
220226 let reportRect : ( ( rect : { width : number ; height : number } ) => void ) | undefined
221227 let batchingColdSizes = false
@@ -267,6 +273,11 @@ export function createTimelineVirtualizer(input: Input) {
267273 flushTouchAdjustment ( )
268274
269275 if ( ! scrolling && touchStart === undefined ) finishTouchScroll ( )
276+
277+ if ( scrolling || touchStart !== undefined || ! endPending ) return
278+ endPending = false
279+
280+ if ( root && ! input . pinned ( ) && ! rendering . anchor && atReachableEnd ( root ) ) input . onPin ( )
270281 } )
271282 settleColdBottom ( )
272283 }
@@ -400,8 +411,7 @@ export function createTimelineVirtualizer(input: Input) {
400411 const root = listRoot ( )
401412
402413 // Reopening a settled scroll-to-end operation can fight subsequent keyboard scrolling.
403- if ( root && Math . abs ( root . scrollHeight - root . clientHeight - root . scrollTop ) > endEpsilon )
404- virtualizer . scrollToEnd ( )
414+ if ( root && ! atReachableEnd ( root ) ) virtualizer . scrollToEnd ( )
405415 } )
406416 }
407417
@@ -611,19 +621,39 @@ export function createTimelineVirtualizer(input: Input) {
611621 let contentObserver : MutationObserver | undefined
612622 let viewportObserver : ResizeObserver | undefined
613623
624+ // Within the zoom slack only a write finds the end the browser reaches: one that does not move proves the view is
625+ // already there, and one that does is undone before anything paints.
626+ function atReachableEnd ( root : HTMLDivElement ) {
627+ const top = root . scrollTop
628+ const distance = root . scrollHeight - root . clientHeight - top
629+
630+ if ( distance <= endEpsilon ) return true
631+
632+ if ( distance > zoomSlack ) return false
633+ root . scrollTop = root . scrollHeight
634+
635+ if ( root . scrollTop === top ) return true
636+ root . scrollTop = top
637+
638+ return false
639+ }
640+
641+ // Returns whether the view now rests at its end with that offset reported.
614642 const pinColdBottom = ( ) => {
615- if ( ! active ( ) ) return
643+ if ( ! active ( ) ) return false
616644 const root = listRoot ( )
617645
618- if ( ! input . pinned ( ) || ! virtualContent || ! root ) return
646+ if ( ! input . pinned ( ) || ! virtualContent || ! root ) return false
619647 // scrollToEnd computes its target from the DOM, not the new size cache.
620648 virtualContent . style . height = `${ virtualizer . getTotalSize ( ) } px`
621649
622- if ( Math . abs ( root . scrollHeight - root . clientHeight - root . scrollTop ) > endEpsilon ) virtualizer . scrollToEnd ( )
650+ if ( ! atReachableEnd ( root ) ) virtualizer . scrollToEnd ( )
623651
624652 // Report after core size adjustments finish so they cannot apply a delta
625653 // twice. This avoids waiting a frame for the native scroll event.
626654 if ( virtualizer . scrollOffset !== root . scrollTop ) reportOffset ?.( root . scrollTop , false )
655+
656+ return true
627657 }
628658
629659 const pendingMeasurements = ( ) => {
@@ -655,12 +685,20 @@ export function createTimelineVirtualizer(input: Input) {
655685 } )
656686
657687 if ( pendingSizes . size || pendingMeasurements ( ) ) return
658- pinColdBottom ( )
688+ const pinned = input . pinned ( )
659689
660- if ( input . pinned ( ) && Math . abs ( root . scrollHeight - root . clientHeight - root . scrollTop ) > 1 ) return
690+ // A pinned view rests at the end pinColdBottom reaches. Under page zoom that end can be more than a pixel short
691+ // of scrollHeight - clientHeight, and TanStack keeps the offset it wrote rather than the one reached. Waiting
692+ // for either distance to close never ends: each pass reported the offset again, which queued the next pass as
693+ // a microtask, and the renderer froze.
694+ if ( pinned && ! pinColdBottom ( ) ) return
661695
662696 // The scroll event must update the range before newly exposed rows can reveal.
663- if ( root . scrollHeight > root . clientHeight && Math . abs ( ( virtualizer . scrollOffset ?? 0 ) - root . scrollTop ) > 1 )
697+ if (
698+ ! pinned &&
699+ root . scrollHeight > root . clientHeight &&
700+ Math . abs ( ( virtualizer . scrollOffset ?? 0 ) - root . scrollTop ) > 1
701+ )
664702 return
665703
666704 if ( rendering . initialTail ) {
@@ -837,9 +875,13 @@ export function createTimelineVirtualizer(input: Input) {
837875 const atEnd = maxScroll - scrollTop <= endEpsilon
838876 const arrived = scrollTop > previousTop + endEpsilon || maxScroll < previousMaxScroll
839877
878+ if ( scrollTop < previousTop ) endPending = false
879+
840880 // An anchor holds its tool even when reaching it lands at the end; only the user's own scroll lets it go.
841881 if ( maxScroll <= 1 || ( atEnd && arrived && ! rendering . anchor ) ) input . onPin ( )
842882 else if ( ( pointerHeld || touchScrolling ) && scrollTop < previousTop - endEpsilon ) input . onUnpin ( )
883+ // Probing the zoomed end now would cancel a scroll still in motion, so it waits for the scroll to settle.
884+ else if ( arrived && ! rendering . anchor && maxScroll - scrollTop <= zoomSlack ) endPending = true
843885 settleColdBottom ( )
844886 input . onScheduleScrollState ( root )
845887 input . onHistoryScroll ( )
0 commit comments