@@ -348,6 +348,22 @@ export interface VirtualizerOptions<
348348 useAnimationFrameWithResizeObserver ?: boolean
349349}
350350
351+ type ScrollState = {
352+ // what we want
353+ index : number | null
354+ align : ScrollAlignment
355+ behavior : ScrollBehavior
356+
357+ // lifecycle
358+ startedAt : number
359+
360+ // target tracking
361+ lastTargetOffset : number
362+
363+ // settling
364+ stableFrames : number
365+ }
366+
351367export class Virtualizer <
352368 TScrollElement extends Element | Window ,
353369 TItemElement extends Element ,
@@ -357,7 +373,7 @@ export class Virtualizer<
357373 scrollElement : TScrollElement | null = null
358374 targetWindow : ( Window & typeof globalThis ) | null = null
359375 isScrolling = false
360- private currentScrollToIndex : number | null = null
376+ private scrollState : ScrollState | null = null
361377 measurementsCache : Array < VirtualItem > = [ ]
362378 private itemSizeCache = new Map < Key , number > ( )
363379 private laneAssignments = new Map < number , number > ( ) // index → lane cache
@@ -535,6 +551,9 @@ export class Virtualizer<
535551 this . scrollOffset = offset
536552 this . isScrolling = isScrolling
537553
554+ if ( this . scrollState ) {
555+ this . scheduleScrollReconcile ( )
556+ }
538557 this . maybeNotify ( )
539558 } ) ,
540559 )
@@ -546,6 +565,63 @@ export class Virtualizer<
546565 }
547566 }
548567
568+ private rafId : number | null = null
569+ private scheduleScrollReconcile ( ) {
570+ if ( ! this . targetWindow ) return
571+ if ( this . rafId != null ) return
572+ this . rafId = this . targetWindow . requestAnimationFrame ( ( ) => {
573+ this . rafId = null
574+ this . reconcileScroll ( )
575+ } )
576+ }
577+ private reconcileScroll ( ) {
578+ if ( ! this . scrollState ) return
579+
580+ const el = this . scrollElement
581+ if ( ! el ) return
582+
583+ const targetOffset = this . scrollState . index
584+ ? this . getOffsetForIndex (
585+ this . scrollState . index ,
586+ this . scrollState . align ,
587+ ) [ 0 ]
588+ : this . getOffsetForAlignment (
589+ this . scrollState . lastTargetOffset ,
590+ this . scrollState . align ,
591+ )
592+
593+ // Require one stable frame where target matches scroll offset.
594+ // approxEqual() already tolerates minor fluctuations, so one frame is sufficient
595+ // to confirm scroll has reached its target without premature cleanup.
596+ const STABLE_FRAMES = 1
597+
598+ const targetChanged = targetOffset !== this . scrollState . lastTargetOffset
599+
600+ if ( ! targetChanged && approxEqual ( targetOffset , this . getScrollOffset ( ) ) ) {
601+ this . scrollState . stableFrames ++
602+ if ( this . scrollState . stableFrames >= STABLE_FRAMES ) {
603+ this . scrollState = null
604+ } else {
605+ this . scheduleScrollReconcile ( )
606+ }
607+ return
608+ }
609+
610+ this . scrollState . stableFrames = 0
611+
612+ if ( targetChanged ) {
613+ this . scrollState . lastTargetOffset = targetOffset
614+ // Switch to 'auto' behavior once measurements cause target to change
615+ // We want to jump directly to the correct position, not smoothly animate to it
616+ this . scrollState . behavior = 'auto'
617+
618+ this . _scrollToOffset ( targetOffset , {
619+ adjustments : undefined ,
620+ behavior : 'auto' ,
621+ } )
622+ }
623+ }
624+
549625 private getSize = ( ) => {
550626 if ( ! this . options . enabled ) {
551627 this . scrollRect = null
@@ -864,6 +940,38 @@ export class Virtualizer<
864940 return parseInt ( indexStr , 10 )
865941 }
866942
943+ /**
944+ * Determines if an item at the given index should be measured during smooth scroll.
945+ * During smooth scroll, only items within a buffer range around the target are measured
946+ * to prevent items far from the target from pushing it away.
947+ */
948+ private shouldMeasureDuringScroll = ( index : number ) : boolean => {
949+ // No scroll state or not smooth scroll - always allow measurements
950+ if ( ! this . scrollState || this . scrollState . behavior !== 'smooth' ) {
951+ return true
952+ }
953+
954+ const scrollIndex =
955+ this . scrollState . index ??
956+ this . getVirtualItemForOffset ( this . scrollState . lastTargetOffset ) ?. index
957+
958+ if ( scrollIndex !== undefined && this . range ) {
959+ // Allow measurements within a buffer range around the scroll target
960+ const bufferSize = Math . max (
961+ this . options . overscan ,
962+ Math . ceil ( ( this . range . endIndex - this . range . startIndex ) / 2 ) ,
963+ )
964+ const minIndex = Math . max ( 0 , scrollIndex - bufferSize )
965+ const maxIndex = Math . min (
966+ this . options . count - 1 ,
967+ scrollIndex + bufferSize ,
968+ )
969+ return index >= minIndex && index <= maxIndex
970+ }
971+
972+ return true
973+ }
974+
867975 private _measureElement = (
868976 node : TItemElement ,
869977 entry : ResizeObserverEntry | undefined ,
@@ -884,7 +992,7 @@ export class Virtualizer<
884992 this . elementsCache . set ( key , node )
885993 }
886994
887- if ( node . isConnected ) {
995+ if ( node . isConnected && this . shouldMeasureDuringScroll ( index ) ) {
888996 this . resizeItem ( index , this . options . measureElement ( node , entry , this ) )
889997 }
890998 }
@@ -899,14 +1007,14 @@ export class Virtualizer<
8991007
9001008 if ( delta !== 0 ) {
9011009 if (
902- this . shouldAdjustScrollPositionOnItemSizeChange !== undefined
1010+ this . scrollState ?. behavior !== 'smooth' &&
1011+ ( this . shouldAdjustScrollPositionOnItemSizeChange !== undefined
9031012 ? this . shouldAdjustScrollPositionOnItemSizeChange ( item , delta , this )
904- : item . start < this . getScrollOffset ( ) + this . scrollAdjustments
1013+ : item . start < this . getScrollOffset ( ) + this . scrollAdjustments )
9051014 ) {
9061015 if ( process . env . NODE_ENV !== 'production' && this . options . debug ) {
9071016 console . info ( 'correction' , delta )
9081017 }
909-
9101018 this . _scrollToOffset ( this . getScrollOffset ( ) , {
9111019 adjustments : ( this . scrollAdjustments += delta ) ,
9121020 behavior : undefined ,
@@ -1018,14 +1126,15 @@ export class Virtualizer<
10181126 getOffsetForIndex = ( index : number , align : ScrollAlignment = 'auto' ) => {
10191127 index = Math . max ( 0 , Math . min ( index , this . options . count - 1 ) )
10201128
1129+ const size = this . getSize ( )
1130+ const scrollOffset = this . getScrollOffset ( )
1131+
10211132 const item = this . measurementsCache [ index ]
10221133 if ( ! item ) {
1023- return undefined
1134+ console . warn ( 'No measurement found for index:' , index )
1135+ return [ scrollOffset , align ] as const
10241136 }
10251137
1026- const size = this . getSize ( )
1027- const scrollOffset = this . getScrollOffset ( )
1028-
10291138 if ( align === 'auto' ) {
10301139 if ( item . end >= scrollOffset + size - this . options . scrollPaddingEnd ) {
10311140 align = 'end'
@@ -1053,110 +1162,73 @@ export class Virtualizer<
10531162 ] as const
10541163 }
10551164
1056- private isDynamicMode = ( ) => this . elementsCache . size > 0
1057-
10581165 scrollToOffset = (
10591166 toOffset : number ,
1060- { align = 'start' , behavior } : ScrollToOffsetOptions = { } ,
1167+ { align = 'start' , behavior = 'auto' } : ScrollToOffsetOptions = { } ,
10611168 ) => {
1062- if ( behavior === 'smooth' && this . isDynamicMode ( ) ) {
1063- console . warn (
1064- 'The `smooth` scroll behavior is not fully supported with dynamic size.' ,
1065- )
1066- }
1169+ const offset = this . getOffsetForAlignment ( toOffset , align )
10671170
1068- this . _scrollToOffset ( this . getOffsetForAlignment ( toOffset , align ) , {
1069- adjustments : undefined ,
1171+ const now = performance . now ( )
1172+ this . scrollState = {
1173+ index : null ,
1174+ align,
10701175 behavior,
1071- } )
1176+ startedAt : now ,
1177+ lastTargetOffset : offset ,
1178+ stableFrames : 0 ,
1179+ }
1180+
1181+ this . _scrollToOffset ( offset , { adjustments : undefined , behavior } )
1182+
1183+ this . scheduleScrollReconcile ( )
10721184 }
10731185
10741186 scrollToIndex = (
10751187 index : number ,
1076- { align : initialAlign = 'auto' , behavior } : ScrollToIndexOptions = { } ,
1188+ {
1189+ align : initialAlign = 'auto' ,
1190+ behavior = 'auto' ,
1191+ } : ScrollToIndexOptions = { } ,
10771192 ) => {
1078- if ( behavior === 'smooth' && this . isDynamicMode ( ) ) {
1079- console . warn (
1080- 'The `smooth` scroll behavior is not fully supported with dynamic size.' ,
1081- )
1082- }
1083-
10841193 index = Math . max ( 0 , Math . min ( index , this . options . count - 1 ) )
1085- this . currentScrollToIndex = index
1086-
1087- let attempts = 0
1088- const maxAttempts = 10
1089-
1090- const tryScroll = ( currentAlign : ScrollAlignment ) => {
1091- if ( ! this . targetWindow ) return
1092-
1093- const offsetInfo = this . getOffsetForIndex ( index , currentAlign )
1094- if ( ! offsetInfo ) {
1095- console . warn ( 'Failed to get offset for index:' , index )
1096- return
1097- }
1098- const [ offset , align ] = offsetInfo
1099- this . _scrollToOffset ( offset , { adjustments : undefined , behavior } )
1100-
1101- this . targetWindow . requestAnimationFrame ( ( ) => {
1102- const verify = ( ) => {
1103- // Abort if a new scrollToIndex was called with a different index
1104- if ( this . currentScrollToIndex !== index ) return
1105-
1106- const currentOffset = this . getScrollOffset ( )
1107- const afterInfo = this . getOffsetForIndex ( index , align )
1108- if ( ! afterInfo ) {
1109- console . warn ( 'Failed to get offset for index:' , index )
1110- return
1111- }
11121194
1113- if ( ! approxEqual ( afterInfo [ 0 ] , currentOffset ) ) {
1114- scheduleRetry ( align )
1115- }
1116- }
1195+ const offsetInfo = this . getOffsetForIndex ( index , initialAlign )
1196+ const [ offset , align ] = offsetInfo
11171197
1118- // In dynamic mode, wait an extra frame for ResizeObserver to measure newly visible elements
1119- if ( this . isDynamicMode ( ) ) {
1120- this . targetWindow ! . requestAnimationFrame ( verify )
1121- } else {
1122- verify ( )
1123- }
1124- } )
1198+ const now = performance . now ( )
1199+ this . scrollState = {
1200+ index,
1201+ align,
1202+ behavior,
1203+ startedAt : now ,
1204+ lastTargetOffset : offset ,
1205+ stableFrames : 0 ,
11251206 }
11261207
1127- const scheduleRetry = ( align : ScrollAlignment ) => {
1128- if ( ! this . targetWindow ) return
1129-
1130- // Abort if a new scrollToIndex was called with a different index
1131- if ( this . currentScrollToIndex !== index ) return
1208+ this . _scrollToOffset ( offset , { adjustments : undefined , behavior } )
11321209
1133- attempts ++
1134- if ( attempts < maxAttempts ) {
1135- if ( process . env . NODE_ENV !== 'production' && this . options . debug ) {
1136- console . info ( 'Schedule retry' , attempts , maxAttempts )
1137- }
1138- this . targetWindow . requestAnimationFrame ( ( ) => tryScroll ( align ) )
1139- } else {
1140- console . warn (
1141- `Failed to scroll to index ${ index } after ${ maxAttempts } attempts.` ,
1142- )
1143- }
1144- }
1145-
1146- tryScroll ( initialAlign )
1210+ this . scheduleScrollReconcile ( )
11471211 }
11481212
1149- scrollBy = ( delta : number , { behavior } : ScrollToOffsetOptions = { } ) => {
1150- if ( behavior === 'smooth' && this . isDynamicMode ( ) ) {
1151- console . warn (
1152- 'The `smooth` scroll behavior is not fully supported with dynamic size.' ,
1153- )
1154- }
1213+ scrollBy = (
1214+ delta : number ,
1215+ { behavior = 'auto' } : ScrollToOffsetOptions = { } ,
1216+ ) => {
1217+ const offset = this . getScrollOffset ( ) + delta
1218+ const now = performance . now ( )
11551219
1156- this . _scrollToOffset ( this . getScrollOffset ( ) + delta , {
1157- adjustments : undefined ,
1220+ this . scrollState = {
1221+ index : null ,
1222+ align : 'start' ,
11581223 behavior,
1159- } )
1224+ startedAt : now ,
1225+ lastTargetOffset : offset ,
1226+ stableFrames : 0 ,
1227+ }
1228+
1229+ this . _scrollToOffset ( offset , { adjustments : undefined , behavior } )
1230+
1231+ this . scheduleScrollReconcile ( )
11601232 }
11611233
11621234 getTotalSize = ( ) => {
0 commit comments