@@ -49,20 +49,28 @@ function init(dotNetHelper: DotNet.DotNetObject, spacerBefore: HTMLElement, spac
4949 return ;
5050 }
5151
52- // Overflow anchoring can cause an ongoing scroll loop, because when we resize the spacers, the browser
53- // would update the scroll position to compensate. Then the spacer would remain visible and we'd keep on
54- // trying to resize it.
5552 const scrollContainer = findClosestScrollContainer ( spacerBefore ) ;
5653 const scrollElement = scrollContainer || document . documentElement ;
57- scrollElement . style . overflowAnchor = 'none' ;
54+ const isTable = isValidTableElement ( spacerAfter . parentElement ) ;
55+ const supportsAnchor = CSS . supports ( 'overflow-anchor' , 'auto' ) ;
56+ const useNativeAnchoring = ! isTable && supportsAnchor ;
5857
5958 const rangeBetweenSpacers = document . createRange ( ) ;
6059
61- if ( isValidTableElement ( spacerAfter . parentElement ) ) {
60+ if ( isTable ) {
6261 spacerBefore . style . display = 'table-row' ;
6362 spacerAfter . style . display = 'table-row' ;
6463 }
6564
65+ if ( useNativeAnchoring ) {
66+ // Prevent spacers from being used as scroll anchors — only rendered items should anchor.
67+ spacerBefore . style . overflowAnchor = 'none' ;
68+ spacerAfter . style . overflowAnchor = 'none' ;
69+ } else {
70+ // Manual compensation path for tables and browsers without native anchoring.
71+ scrollElement . style . overflowAnchor = 'none' ;
72+ }
73+
6674 const intersectionObserver = new IntersectionObserver ( intersectionCallback , {
6775 root : scrollContainer ,
6876 rootMargin : `${ rootMargin } px` ,
@@ -74,10 +82,48 @@ function init(dotNetHelper: DotNet.DotNetObject, spacerBefore: HTMLElement, spac
7482 let convergingElements = false ;
7583 let convergenceItems : Set < Element > = new Set ( ) ;
7684
77- // ResizeObserver roles:
85+ const anchoredItems : Map < Element , number > = new Map ( ) ;
86+ let scrollTriggeredRender = false ;
87+
88+ function getObservedHeight ( entry : ResizeObserverEntry ) : number {
89+ return entry . borderBoxSize ?. [ 0 ] ?. blockSize ?? entry . contentRect . height ;
90+ }
91+
92+ function compensateScrollForItemResizes ( entries : ResizeObserverEntry [ ] ) : void {
93+ let scrollDelta = 0 ;
94+ const containerTop = scrollContainer
95+ ? scrollContainer . getBoundingClientRect ( ) . top
96+ : 0 ;
97+
98+ for ( const entry of entries ) {
99+ if ( entry . target === spacerBefore || entry . target === spacerAfter ) {
100+ continue ;
101+ }
102+
103+ if ( entry . target . isConnected ) {
104+ const el = entry . target as HTMLElement ;
105+ const oldHeight = anchoredItems . get ( el ) ;
106+ const newHeight = getObservedHeight ( entry ) ;
107+ anchoredItems . set ( el , newHeight ) ;
108+
109+ if ( oldHeight !== undefined && oldHeight !== newHeight ) {
110+ if ( el . getBoundingClientRect ( ) . top < containerTop ) {
111+ scrollDelta += ( newHeight - oldHeight ) ;
112+ }
113+ }
114+ }
115+ }
116+
117+ if ( scrollDelta !== 0 && scrollElement . scrollTop > 0 ) {
118+ scrollElement . scrollTop += scrollDelta ;
119+ }
120+ }
121+
122+ // ResizeObserver roles:
78123 // 1. Always observes both spacers so that when a spacer resizes we re-trigger the
79124 // IntersectionObserver — which otherwise won't fire again for an element that is already visible.
80125 // 2. For convergence (sticky-top/bottom) - observes elements for geometry changes, drives the scroll position.
126+ // 3. Manual scroll compensation (tables/Safari) — adjusts scrollTop when above-viewport items resize.
81127 const resizeObserver = new ResizeObserver ( ( entries : ResizeObserverEntry [ ] ) : void => {
82128 for ( const entry of entries ) {
83129 if ( entry . target === spacerBefore || entry . target === spacerAfter ) {
@@ -100,20 +146,29 @@ function init(dotNetHelper: DotNet.DotNetObject, spacerBefore: HTMLElement, spac
100146 } else if ( convergingElements ) {
101147 stopConvergenceObserving ( ) ;
102148 }
149+
150+ // Manual scroll compensation: adjust scrollTop for above-viewport resizes.
151+ if ( ! useNativeAnchoring ) {
152+ compensateScrollForItemResizes ( entries ) ;
153+ }
103154 } ) ;
104155
105156 // Always observe both spacers for the IntersectionObserver re-trigger.
106157 resizeObserver . observe ( spacerBefore ) ;
107158 resizeObserver . observe ( spacerAfter ) ;
108159
109160 function refreshObservedElements ( ) : void {
110- // C# style updates overwrite the entire style attribute, losing display: table-row.
111- // Re-apply it so spacers participate in table layout alongside bare <tr> items.
112- if ( isValidTableElement ( spacerAfter . parentElement ) ) {
161+ // C# style updates overwrite the entire style attribute. Re-apply what we need.
162+ if ( isTable ) {
113163 spacerBefore . style . display = 'table-row' ;
114164 spacerAfter . style . display = 'table-row' ;
115165 }
116166
167+ if ( useNativeAnchoring ) {
168+ spacerBefore . style . overflowAnchor = 'none' ;
169+ spacerAfter . style . overflowAnchor = 'none' ;
170+ }
171+
117172 // Ensure spacers are always observed (idempotent).
118173 resizeObserver . observe ( spacerBefore ) ;
119174 resizeObserver . observe ( spacerAfter ) ;
@@ -132,15 +187,38 @@ function init(dotNetHelper: DotNet.DotNetObject, spacerBefore: HTMLElement, spac
132187 }
133188 }
134189 convergenceItems = currentItems ;
190+ return ;
135191 }
136192
193+ // Manual compensation: observe items so ResizeObserver can compensate scrollTop.
194+ // Skip for native anchoring (browser handles it) and scroll-triggered renders
195+ // (avoids layout interference drift).
196+ if ( ! useNativeAnchoring && ! scrollTriggeredRender ) {
197+ const currentItems = new Set < Element > ( ) ;
198+ for ( let el = spacerBefore . nextElementSibling ; el && el !== spacerAfter ; el = el . nextElementSibling ) {
199+ resizeObserver . observe ( el ) ;
200+ currentItems . add ( el ) ;
201+ }
202+
203+ for ( const [ el ] of anchoredItems ) {
204+ if ( ! currentItems . has ( el ) ) {
205+ resizeObserver . unobserve ( el ) ;
206+ anchoredItems . delete ( el ) ;
207+ }
208+ }
209+ }
210+ scrollTriggeredRender = false ;
211+
137212 // Don't re-trigger IntersectionObserver here — ResizeObserver handles that
138213 // when spacers actually resize. Doing it on every render causes feedback loops.
139214 }
140215
141216 function startConvergenceObserving ( ) : void {
142217 if ( convergingElements ) return ;
143218 convergingElements = true ;
219+ if ( useNativeAnchoring ) {
220+ scrollElement . style . overflowAnchor = 'none' ;
221+ }
144222 for ( let el = spacerBefore . nextElementSibling ; el && el !== spacerAfter ; el = el . nextElementSibling ) {
145223 resizeObserver . observe ( el ) ;
146224 convergenceItems . add ( el ) ;
@@ -154,6 +232,10 @@ function init(dotNetHelper: DotNet.DotNetObject, spacerBefore: HTMLElement, spac
154232 resizeObserver . unobserve ( el ) ;
155233 }
156234 convergenceItems . clear ( ) ;
235+ if ( useNativeAnchoring ) {
236+ scrollElement . style . overflowAnchor = '' ;
237+ }
238+ anchoredItems . clear ( ) ;
157239 }
158240
159241 let convergingToBottom = false ;
@@ -168,9 +250,17 @@ function init(dotNetHelper: DotNet.DotNetObject, spacerBefore: HTMLElement, spac
168250 if ( ke . key === 'End' ) {
169251 pendingJumpToEnd = true ;
170252 pendingJumpToStart = false ;
253+ if ( ! convergingToBottom && spacerAfter . offsetHeight > 0 ) {
254+ convergingToBottom = true ;
255+ startConvergenceObserving ( ) ;
256+ }
171257 } else if ( ke . key === 'Home' ) {
172258 pendingJumpToStart = true ;
173259 pendingJumpToEnd = false ;
260+ if ( ! convergingToTop && spacerBefore . offsetHeight > 0 ) {
261+ convergingToTop = true ;
262+ startConvergenceObserving ( ) ;
263+ }
174264 }
175265 }
176266 keydownTarget . addEventListener ( 'keydown' , handleJumpKeys ) ;
@@ -185,8 +275,10 @@ function init(dotNetHelper: DotNet.DotNetObject, spacerBefore: HTMLElement, spac
185275 refreshObservedElements,
186276 scrollElement,
187277 startConvergenceObserving,
278+ setConvergingToBottom : ( ) => { convergingToBottom = true ; } ,
188279 onDispose : ( ) => {
189280 stopConvergenceObserving ( ) ;
281+ anchoredItems . clear ( ) ;
190282 resizeObserver . disconnect ( ) ;
191283 keydownTarget . removeEventListener ( 'keydown' , handleJumpKeys ) ;
192284 if ( callbackTimeout ) {
@@ -295,6 +387,9 @@ function init(dotNetHelper: DotNet.DotNetObject, spacerBefore: HTMLElement, spac
295387 intersectingEntries . forEach ( ( entry ) : void => {
296388 const containerSize = ( entry . rootBounds ?. height ?? 0 ) / scaleFactor ;
297389
390+ // So that RefreshObservedElements can skip item observation (avoids layout interference drift).
391+ scrollTriggeredRender = true ;
392+
298393 if ( entry . target === spacerBefore ) {
299394 const spacerSize = ( entry . intersectionRect . top - entry . boundingClientRect . top ) / scaleFactor ;
300395 dotNetHelper . invokeMethodAsync ( 'OnSpacerBeforeVisible' , spacerSize , spacerSeparation , containerSize ) ;
@@ -322,6 +417,7 @@ function scrollToBottom(dotNetHelper: DotNet.DotNetObject): void {
322417 const { observersByDotNetObjectId, id } = getObserversMapEntry ( dotNetHelper ) ;
323418 const entry = observersByDotNetObjectId [ id ] ;
324419 if ( entry ) {
420+ entry . setConvergingToBottom ?.( ) ;
325421 entry . scrollElement . scrollTop = entry . scrollElement . scrollHeight ;
326422 entry . startConvergenceObserving ?.( ) ;
327423 }
0 commit comments