@@ -3155,3 +3155,111 @@ test('observeWindowOffset: reads scrollX when horizontal', () => {
31553155 listeners . get ( 'scroll' ) ! ( { } as Event )
31563156 expect ( cb ) . toHaveBeenCalledWith ( 75 , true )
31573157} )
3158+
3159+ // ─── #1229: negative tracked scrollOffset must not survive compensation ─────
3160+ // anchorTo: 'end' + element scrolling + items measuring smaller than their
3161+ // estimates + content shorter than the viewport. The end-anchor compensation
3162+ // applies a negative delta; the DOM clamps the scrollTop write to 0 and an
3163+ // unscrollable element never fires a scroll event, so an unclamped tracked
3164+ // offset would stay negative forever — phantom getDistanceFromEnd(), wedged
3165+ // _flushIosDeferredIfReady.
3166+
3167+ const makeUnscrollableElement = ( ) => {
3168+ // Content fits the viewport: the browser clamps scrollHeight to
3169+ // clientHeight, so maxScrollOffset = 0 and no scroll event can fire.
3170+ const el = {
3171+ scrollTop : 0 ,
3172+ scrollLeft : 0 ,
3173+ scrollWidth : 400 ,
3174+ scrollHeight : 600 ,
3175+ clientWidth : 400 ,
3176+ clientHeight : 600 ,
3177+ scrollTo : ( { top } : { top : number } ) => {
3178+ el . scrollTop = Math . max ( 0 , Math . min ( top , 0 ) )
3179+ } ,
3180+ }
3181+ return el as unknown as HTMLDivElement
3182+ }
3183+
3184+ const unscrollableOptions = (
3185+ scrollElement : HTMLDivElement ,
3186+ offsetCbRef : {
3187+ current : ( ( offset : number , isScrolling : boolean ) => void ) | null
3188+ } ,
3189+ ) => ( {
3190+ count : 5 ,
3191+ // Estimates larger than the real measured sizes
3192+ estimateSize : ( ) => 100 ,
3193+ anchorTo : 'end' as const ,
3194+ getScrollElement : ( ) => scrollElement ,
3195+ scrollToFn : (
3196+ offset : number ,
3197+ {
3198+ adjustments = 0 ,
3199+ behavior,
3200+ } : { adjustments ?: number ; behavior ?: ScrollBehavior } ,
3201+ instance : Virtualizer < HTMLDivElement , any > ,
3202+ ) => {
3203+ instance . scrollElement ?. scrollTo ?.( { top : offset + adjustments , behavior } )
3204+ } ,
3205+ observeElementRect : (
3206+ _instance : unknown ,
3207+ cb : ( rect : { width : number ; height : number } ) => void ,
3208+ ) => {
3209+ cb ( { width : 400 , height : 600 } )
3210+ return ( ) => { }
3211+ } ,
3212+ observeElementOffset : (
3213+ _instance : unknown ,
3214+ cb : ( offset : number , isScrolling : boolean ) => void ,
3215+ ) => {
3216+ offsetCbRef . current = cb
3217+ cb ( 0 , false )
3218+ return ( ) => { }
3219+ } ,
3220+ } )
3221+
3222+ test ( 'anchorTo end: shrink compensation clamps tracked scrollOffset at 0 when content fits the viewport (#1229)' , ( ) => {
3223+ const scrollElement = makeUnscrollableElement ( )
3224+ const offsetCbRef = { current : null as any }
3225+ const virtualizer = new Virtualizer (
3226+ unscrollableOptions ( scrollElement , offsetCbRef ) ,
3227+ )
3228+
3229+ virtualizer . _willUpdate ( )
3230+ virtualizer . getVirtualItems ( )
3231+
3232+ // Real sizes come in smaller than the estimates (98 vs 100)
3233+ for ( let i = 0 ; i < 5 ; i ++ ) {
3234+ virtualizer . resizeItem ( i , 98 )
3235+ }
3236+
3237+ // The element is trivially at its end: 490px of content in a 600px
3238+ // viewport, pinned at scrollTop 0.
3239+ expect ( scrollElement . scrollTop ) . toBe ( 0 )
3240+ expect ( virtualizer . scrollOffset ) . toBe ( 0 )
3241+ expect ( virtualizer . getDistanceFromEnd ( ) ) . toBe ( 0 )
3242+ expect ( virtualizer . isAtEnd ( ) ) . toBe ( true )
3243+ } )
3244+
3245+ test ( 'anchorTo end: setOptions re-anchor clamps tracked scrollOffset at 0 (#1229)' , ( ) => {
3246+ const scrollElement = makeUnscrollableElement ( )
3247+ const offsetCbRef = { current : null as any }
3248+ const options = unscrollableOptions ( scrollElement , offsetCbRef )
3249+ const virtualizer = new Virtualizer ( options )
3250+
3251+ virtualizer . _willUpdate ( )
3252+ virtualizer . getVirtualItems ( )
3253+
3254+ // Simulate a transiently negative offset reported by a real scroll event
3255+ // (elastic overscroll) landing right before an options update.
3256+ offsetCbRef . current ! ( - 10 , false )
3257+ expect ( virtualizer . scrollOffset ) . toBe ( - 10 )
3258+
3259+ // Trim the last item: edge keys change, triggering the end-anchor
3260+ // re-resolution in setOptions. The anchor item (index 0) still starts at
3261+ // 0, so the unclamped offset would be written back as -10.
3262+ virtualizer . setOptions ( { ...options , count : 4 } )
3263+
3264+ expect ( virtualizer . scrollOffset ) . toBe ( 0 )
3265+ } )
0 commit comments