@@ -34,13 +34,16 @@ export default class VariableVirtualList {
3434 this . offsets = [ 0 ] ;
3535 this . offsetsDirty = false ;
3636 this . renderedRange = { start : 0 , end : 0 } ;
37+ this . renderedItemCount = 0 ;
3738 this . frame = null ;
3839 this . stickToBottom = true ;
3940 this . lastScrollTop = this . container . scrollTop ;
4041 this . lastScrollTime = this . now ( ) ;
42+ this . scrollDirection = 0 ;
4143 this . touching = false ;
4244 this . releaseTimer = null ;
4345 this . destroyed = false ;
46+ this . observedItems = new Set ( ) ;
4447
4548 this . topSpacer = document . createElement ( "div" ) ;
4649 this . topSpacer . className = "variable-virtual-spacer" ;
@@ -59,7 +62,11 @@ export default class VariableVirtualList {
5962 this . stickToBottom = this . isNearBottom ( ) ;
6063 const now = this . now ( ) ;
6164 const elapsed = Math . max ( 8 , now - this . lastScrollTime ) ;
62- const distance = Math . abs ( this . container . scrollTop - this . lastScrollTop ) ;
65+ const delta = this . container . scrollTop - this . lastScrollTop ;
66+ const direction = Math . sign ( delta ) || this . scrollDirection ;
67+ const directionChanged = direction !== this . scrollDirection ;
68+ this . scrollDirection = direction ;
69+ const distance = Math . abs ( delta ) ;
6370 const projectedDistance = distance * ( 32 / elapsed ) ;
6471 this . dynamicOverscan = Math . min (
6572 this . maxOverscan ,
@@ -69,13 +76,17 @@ export default class VariableVirtualList {
6976 this . lastScrollTime = now ;
7077 // Scroll rendering is intentionally synchronous. Android WebView can move
7178 // the compositor several rows before the next animation frame.
72- this . render ( ) ;
79+ // Refill immediately on reversal, before relying on the smaller rear buffer.
80+ this . render ( ! directionChanged ) ;
7381 this . scheduleOverscanRelease ( ) ;
7482 } ;
7583 this . onTouchStart = ( ) => {
7684 this . touching = true ;
7785 this . clearOverscanRelease ( ) ;
7886 this . dynamicOverscan = this . activeOverscan ;
87+ this . scrollDirection = 0 ;
88+ this . lastScrollTop = this . container . scrollTop ;
89+ this . lastScrollTime = this . now ( ) ;
7990 // Pre-paint the fling guard before compositor scrolling begins.
8091 this . render ( ) ;
8192 } ;
@@ -99,6 +110,7 @@ export default class VariableVirtualList {
99110 ? new ResizeObserver ( ( entries ) => this . onResize ( entries ) )
100111 : null ;
101112 this . resizeObserver ?. observe ( this . container ) ;
113+ if ( this . footer ) this . resizeObserver ?. observe ( this . footer ) ;
102114 }
103115
104116 now ( ) {
@@ -115,6 +127,7 @@ export default class VariableVirtualList {
115127 this . releaseTimer = null ;
116128 if ( this . destroyed || this . touching ) return ;
117129 this . dynamicOverscan = this . overscan ;
130+ this . scrollDirection = 0 ;
118131 this . render ( ) ;
119132 } ,
120133 500 ,
@@ -144,7 +157,11 @@ export default class VariableVirtualList {
144157 } ;
145158 this . items . push ( item ) ;
146159 this . itemByElement . set ( element , item ) ;
147- this . offsetsDirty = true ;
160+ // Existing offsets remain valid on append. Height changes still take the
161+ // rebuild path, including any messages appended before that rebuild.
162+ if ( ! this . offsetsDirty ) {
163+ this . offsets . push ( this . offsets [ this . offsets . length - 1 ] + item . height ) ;
164+ }
148165 if ( wasNearBottom || this . items . length === 1 ) this . stickToBottom = true ;
149166 this . scheduleRender ( ) ;
150167 }
@@ -155,12 +172,16 @@ export default class VariableVirtualList {
155172 this . offsets = [ 0 ] ;
156173 this . offsetsDirty = false ;
157174 this . renderedRange = { start : 0 , end : 0 } ;
175+ this . renderedItemCount = 0 ;
158176 this . stickToBottom = true ;
159177 this . dynamicOverscan = this . overscan ;
178+ this . scrollDirection = 0 ;
160179 this . topSpacer . style . height = "0px" ;
161180 this . bottomSpacer . style . height = "0px" ;
162181 this . itemContainer . replaceChildren ( ) ;
163182 this . container . scrollTop = 0 ;
183+ this . lastScrollTop = 0 ;
184+ this . lastScrollTime = this . now ( ) ;
164185 this . footerHeight = this . getFooterHeight ( ) ;
165186 this . observeResizeTargets ( ) ;
166187 }
@@ -216,17 +237,56 @@ export default class VariableVirtualList {
216237
217238 observeResizeTargets ( ) {
218239 if ( ! this . resizeObserver ) return ;
219- this . resizeObserver . disconnect ( ) ;
220- this . resizeObserver . observe ( this . container ) ;
221- for ( const element of this . itemContainer . children ) {
222- this . resizeObserver . observe ( element ) ;
240+ const mountedItems = new Set ( this . itemContainer . children ) ;
241+ for ( const element of this . observedItems ) {
242+ if ( ! mountedItems . has ( element ) ) this . resizeObserver . unobserve ( element ) ;
223243 }
224- if ( this . footer ) this . resizeObserver . observe ( this . footer ) ;
244+ for ( const element of mountedItems ) {
245+ if ( ! this . observedItems . has ( element ) )
246+ this . resizeObserver . observe ( element ) ;
247+ }
248+ this . observedItems = mountedItems ;
249+ }
250+
251+ getOverscan ( ) {
252+ const ahead = this . dynamicOverscan ;
253+ // Keep a rear guard for abrupt reversals, while spending most of the
254+ // active buffer on the direction the reader is moving toward.
255+ const behind = Math . max ( this . overscan , ahead / 4 ) ;
256+ return {
257+ before : this . scrollDirection > 0 ? behind : ahead ,
258+ after : this . scrollDirection < 0 ? behind : ahead ,
259+ } ;
225260 }
226261
227- render ( ) {
262+ render ( scrollOnly = false ) {
228263 if ( this . destroyed ) return ;
264+ const overscan = this . getOverscan ( ) ;
265+ // Keep the mounted window stable while there is still a safety buffer on
266+ // both sides. Touch start and scheduled data/measurement updates always
267+ // render, so this shortcut cannot hide appended or resized content.
268+ if (
269+ scrollOnly &&
270+ ! this . offsetsDirty &&
271+ ! this . stickToBottom &&
272+ this . renderedItemCount === this . items . length
273+ ) {
274+ const { start, end } = this . renderedRange ;
275+ const scrollTop = this . container . scrollTop ;
276+ if (
277+ end > start &&
278+ this . container . clientHeight === this . viewportHeight &&
279+ ( start === 0 ||
280+ scrollTop >= this . offsets [ start ] + overscan . before / 2 ) &&
281+ ( end === this . items . length ||
282+ scrollTop + this . viewportHeight <=
283+ this . offsets [ end ] - overscan . after / 2 )
284+ ) {
285+ return ;
286+ }
287+ }
229288 this . rebuildOffsets ( ) ;
289+ this . renderedItemCount = this . items . length ;
230290 if ( ! this . items . length ) {
231291 this . footerHeight = this . getFooterHeight ( ) ;
232292 this . itemContainer . replaceChildren ( ) ;
@@ -248,13 +308,11 @@ export default class VariableVirtualList {
248308 ? Math . max ( 0 , totalHeight + this . footerHeight - viewportHeight )
249309 : this . container . scrollTop ;
250310 const start = this . findIndexAt (
251- Math . max ( 0 , targetScrollTop - this . dynamicOverscan ) ,
311+ Math . max ( 0 , targetScrollTop - overscan . before ) ,
252312 ) ;
253313 const end = Math . min (
254314 this . items . length ,
255- this . findIndexAt (
256- targetScrollTop + viewportHeight + this . dynamicOverscan ,
257- ) + 1 ,
315+ this . findIndexAt ( targetScrollTop + viewportHeight + overscan . after ) + 1 ,
258316 ) ;
259317
260318 this . updateMountedRange ( start , end ) ;
@@ -399,6 +457,7 @@ export default class VariableVirtualList {
399457 this . frame = null ;
400458 this . clearOverscanRelease ( ) ;
401459 this . resizeObserver ?. disconnect ( ) ;
460+ this . observedItems . clear ( ) ;
402461 this . container . removeEventListener ( "scroll" , this . onScroll ) ;
403462 this . container . removeEventListener ( "touchstart" , this . onTouchStart ) ;
404463 this . container . removeEventListener ( "touchend" , this . onTouchEnd ) ;
0 commit comments