@@ -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
@@ -859,6 +935,38 @@ export class Virtualizer<
859935 return parseInt ( indexStr , 10 )
860936 }
861937
938+ /**
939+ * Determines if an item at the given index should be measured during smooth scroll.
940+ * During smooth scroll, only items within a buffer range around the target are measured
941+ * to prevent items far from the target from pushing it away.
942+ */
943+ private shouldMeasureDuringScroll = ( index : number ) : boolean => {
944+ // No scroll state or not smooth scroll - always allow measurements
945+ if ( ! this . scrollState || this . scrollState . behavior !== 'smooth' ) {
946+ return true
947+ }
948+
949+ const scrollIndex =
950+ this . scrollState . index ??
951+ this . getVirtualItemForOffset ( this . scrollState . lastTargetOffset ) ?. index
952+
953+ if ( scrollIndex !== undefined && this . range ) {
954+ // Allow measurements within a buffer range around the scroll target
955+ const bufferSize = Math . max (
956+ this . options . overscan ,
957+ Math . ceil ( ( this . range . endIndex - this . range . startIndex ) / 2 ) ,
958+ )
959+ const minIndex = Math . max ( 0 , scrollIndex - bufferSize )
960+ const maxIndex = Math . min (
961+ this . options . count - 1 ,
962+ scrollIndex + bufferSize ,
963+ )
964+ return index >= minIndex && index <= maxIndex
965+ }
966+
967+ return true
968+ }
969+
862970 private _measureElement = (
863971 node : TItemElement ,
864972 entry : ResizeObserverEntry | undefined ,
@@ -879,7 +987,7 @@ export class Virtualizer<
879987 this . elementsCache . set ( key , node )
880988 }
881989
882- if ( node . isConnected ) {
990+ if ( node . isConnected && this . shouldMeasureDuringScroll ( index ) ) {
883991 this . resizeItem ( index , this . options . measureElement ( node , entry , this ) )
884992 }
885993 }
@@ -894,14 +1002,14 @@ export class Virtualizer<
8941002
8951003 if ( delta !== 0 ) {
8961004 if (
897- this . shouldAdjustScrollPositionOnItemSizeChange !== undefined
1005+ this . scrollState ?. behavior !== 'smooth' &&
1006+ ( this . shouldAdjustScrollPositionOnItemSizeChange !== undefined
8981007 ? this . shouldAdjustScrollPositionOnItemSizeChange ( item , delta , this )
899- : item . start < this . getScrollOffset ( ) + this . scrollAdjustments
1008+ : item . start < this . getScrollOffset ( ) + this . scrollAdjustments )
9001009 ) {
9011010 if ( process . env . NODE_ENV !== 'production' && this . options . debug ) {
9021011 console . info ( 'correction' , delta )
9031012 }
904-
9051013 this . _scrollToOffset ( this . getScrollOffset ( ) , {
9061014 adjustments : ( this . scrollAdjustments += delta ) ,
9071015 behavior : undefined ,
@@ -1013,14 +1121,15 @@ export class Virtualizer<
10131121 getOffsetForIndex = ( index : number , align : ScrollAlignment = 'auto' ) => {
10141122 index = Math . max ( 0 , Math . min ( index , this . options . count - 1 ) )
10151123
1124+ const size = this . getSize ( )
1125+ const scrollOffset = this . getScrollOffset ( )
1126+
10161127 const item = this . measurementsCache [ index ]
10171128 if ( ! item ) {
1018- return undefined
1129+ console . warn ( 'No measurement found for index:' , index )
1130+ return [ scrollOffset , align ] as const
10191131 }
10201132
1021- const size = this . getSize ( )
1022- const scrollOffset = this . getScrollOffset ( )
1023-
10241133 if ( align === 'auto' ) {
10251134 if ( item . end >= scrollOffset + size - this . options . scrollPaddingEnd ) {
10261135 align = 'end'
@@ -1048,110 +1157,73 @@ export class Virtualizer<
10481157 ] as const
10491158 }
10501159
1051- private isDynamicMode = ( ) => this . elementsCache . size > 0
1052-
10531160 scrollToOffset = (
10541161 toOffset : number ,
1055- { align = 'start' , behavior } : ScrollToOffsetOptions = { } ,
1162+ { align = 'start' , behavior = 'auto' } : ScrollToOffsetOptions = { } ,
10561163 ) => {
1057- if ( behavior === 'smooth' && this . isDynamicMode ( ) ) {
1058- console . warn (
1059- 'The `smooth` scroll behavior is not fully supported with dynamic size.' ,
1060- )
1061- }
1164+ const offset = this . getOffsetForAlignment ( toOffset , align )
10621165
1063- this . _scrollToOffset ( this . getOffsetForAlignment ( toOffset , align ) , {
1064- adjustments : undefined ,
1166+ const now = performance . now ( )
1167+ this . scrollState = {
1168+ index : null ,
1169+ align,
10651170 behavior,
1066- } )
1171+ startedAt : now ,
1172+ lastTargetOffset : offset ,
1173+ stableFrames : 0 ,
1174+ }
1175+
1176+ this . _scrollToOffset ( offset , { adjustments : undefined , behavior } )
1177+
1178+ this . scheduleScrollReconcile ( )
10671179 }
10681180
10691181 scrollToIndex = (
10701182 index : number ,
1071- { align : initialAlign = 'auto' , behavior } : ScrollToIndexOptions = { } ,
1183+ {
1184+ align : initialAlign = 'auto' ,
1185+ behavior = 'auto' ,
1186+ } : ScrollToIndexOptions = { } ,
10721187 ) => {
1073- if ( behavior === 'smooth' && this . isDynamicMode ( ) ) {
1074- console . warn (
1075- 'The `smooth` scroll behavior is not fully supported with dynamic size.' ,
1076- )
1077- }
1078-
10791188 index = Math . max ( 0 , Math . min ( index , this . options . count - 1 ) )
1080- this . currentScrollToIndex = index
1081-
1082- let attempts = 0
1083- const maxAttempts = 10
1084-
1085- const tryScroll = ( currentAlign : ScrollAlignment ) => {
1086- if ( ! this . targetWindow ) return
1087-
1088- const offsetInfo = this . getOffsetForIndex ( index , currentAlign )
1089- if ( ! offsetInfo ) {
1090- console . warn ( 'Failed to get offset for index:' , index )
1091- return
1092- }
1093- const [ offset , align ] = offsetInfo
1094- this . _scrollToOffset ( offset , { adjustments : undefined , behavior } )
1095-
1096- this . targetWindow . requestAnimationFrame ( ( ) => {
1097- const verify = ( ) => {
1098- // Abort if a new scrollToIndex was called with a different index
1099- if ( this . currentScrollToIndex !== index ) return
1100-
1101- const currentOffset = this . getScrollOffset ( )
1102- const afterInfo = this . getOffsetForIndex ( index , align )
1103- if ( ! afterInfo ) {
1104- console . warn ( 'Failed to get offset for index:' , index )
1105- return
1106- }
11071189
1108- if ( ! approxEqual ( afterInfo [ 0 ] , currentOffset ) ) {
1109- scheduleRetry ( align )
1110- }
1111- }
1190+ const offsetInfo = this . getOffsetForIndex ( index , initialAlign )
1191+ const [ offset , align ] = offsetInfo
11121192
1113- // In dynamic mode, wait an extra frame for ResizeObserver to measure newly visible elements
1114- if ( this . isDynamicMode ( ) ) {
1115- this . targetWindow ! . requestAnimationFrame ( verify )
1116- } else {
1117- verify ( )
1118- }
1119- } )
1193+ const now = performance . now ( )
1194+ this . scrollState = {
1195+ index,
1196+ align,
1197+ behavior,
1198+ startedAt : now ,
1199+ lastTargetOffset : offset ,
1200+ stableFrames : 0 ,
11201201 }
11211202
1122- const scheduleRetry = ( align : ScrollAlignment ) => {
1123- if ( ! this . targetWindow ) return
1124-
1125- // Abort if a new scrollToIndex was called with a different index
1126- if ( this . currentScrollToIndex !== index ) return
1203+ this . _scrollToOffset ( offset , { adjustments : undefined , behavior } )
11271204
1128- attempts ++
1129- if ( attempts < maxAttempts ) {
1130- if ( process . env . NODE_ENV !== 'production' && this . options . debug ) {
1131- console . info ( 'Schedule retry' , attempts , maxAttempts )
1132- }
1133- this . targetWindow . requestAnimationFrame ( ( ) => tryScroll ( align ) )
1134- } else {
1135- console . warn (
1136- `Failed to scroll to index ${ index } after ${ maxAttempts } attempts.` ,
1137- )
1138- }
1139- }
1140-
1141- tryScroll ( initialAlign )
1205+ this . scheduleScrollReconcile ( )
11421206 }
11431207
1144- scrollBy = ( delta : number , { behavior } : ScrollToOffsetOptions = { } ) => {
1145- if ( behavior === 'smooth' && this . isDynamicMode ( ) ) {
1146- console . warn (
1147- 'The `smooth` scroll behavior is not fully supported with dynamic size.' ,
1148- )
1149- }
1208+ scrollBy = (
1209+ delta : number ,
1210+ { behavior = 'auto' } : ScrollToOffsetOptions = { } ,
1211+ ) => {
1212+ const offset = this . getScrollOffset ( ) + delta
1213+ const now = performance . now ( )
11501214
1151- this . _scrollToOffset ( this . getScrollOffset ( ) + delta , {
1152- adjustments : undefined ,
1215+ this . scrollState = {
1216+ index : null ,
1217+ align : 'start' ,
11531218 behavior,
1154- } )
1219+ startedAt : now ,
1220+ lastTargetOffset : offset ,
1221+ stableFrames : 0 ,
1222+ }
1223+
1224+ this . _scrollToOffset ( offset , { adjustments : undefined , behavior } )
1225+
1226+ this . scheduleScrollReconcile ( )
11551227 }
11561228
11571229 getTotalSize = ( ) => {
0 commit comments