@@ -76,26 +76,33 @@ export function VirtualElement({
7676 const isTransitioning = useRef ( false )
7777
7878 const isCurrentlyObserving = useRef ( false )
79+ const observedElementRef = useRef < HTMLDivElement | null > ( null )
80+ const isMountedRef = useRef ( true )
81+
82+ useEffect ( ( ) => {
83+ return ( ) => {
84+ isMountedRef . current = false
85+ }
86+ } , [ ] )
87+
88+ const placeholderHeightPx = measurements ?. clientHeight ?? placeholderHeight ?? ref ?. clientHeight ?? 0
7989
8090 const styleObj = useMemo < React . CSSProperties > (
8191 ( ) => ( {
8292 width : width ?? 'auto' ,
83- height : ( ( placeholderHeight || ref ?. clientHeight ) ?? '0' ) + 'px' ,
84- // These properties are used to ensure that if a prior element is changed from
85- // placeHolder to element, the position of visible elements are not affected.
86- contentVisibility : 'auto' ,
87- containIntrinsicSize : `0 ${ ( placeholderHeight || ref ?. clientHeight ) ?? '0' } px` ,
88- contain : 'size layout' ,
93+ height : `${ placeholderHeightPx } px` ,
8994 } ) ,
90- [ width , placeholderHeight ]
95+ [ width , placeholderHeightPx ]
9196 )
9297
9398 const handleResize = useCallback ( ( ) => {
99+ if ( ! isMountedRef . current ) return
94100 if ( ref ) {
95101 // Show children during measurement
96102 setIsShowingChildren ( true )
97103
98104 requestAnimationFrame ( ( ) => {
105+ if ( ! isMountedRef . current ) return
99106 const measurements = measureElement ( ref , placeholderHeight )
100107 if ( measurements ) {
101108 setMeasurements ( measurements )
@@ -111,6 +118,18 @@ export function VirtualElement({
111118 }
112119 } , [ ref , inView , placeholderHeight ] )
113120
121+ const unobserveElement = useCallback (
122+ ( element : HTMLDivElement | null ) => {
123+ if ( ! element ) return
124+ resizeObserverManager . unobserve ( element )
125+ if ( observedElementRef . current === element ) {
126+ observedElementRef . current = null
127+ }
128+ isCurrentlyObserving . current = false
129+ } ,
130+ [ resizeObserverManager ]
131+ )
132+
114133 // failsafe to ensure visible elements if resizing happens while scrolling
115134 useEffect ( ( ) => {
116135 if ( ! isShowingChildren ) {
@@ -165,6 +184,10 @@ export function VirtualElement({
165184 } , [ inView , isShowingChildren ] )
166185
167186 useEffect ( ( ) => {
187+ if ( observedElementRef . current && observedElementRef . current !== ref ) {
188+ unobserveElement ( observedElementRef . current )
189+ }
190+
168191 if ( inView ) {
169192 setIsShowingChildren ( true )
170193 }
@@ -195,14 +218,12 @@ export function VirtualElement({
195218 if ( ref ) {
196219 if ( ! isCurrentlyObserving . current ) {
197220 resizeObserverManager . observe ( ref , handleResize )
221+ observedElementRef . current = ref
198222 isCurrentlyObserving . current = true
199223 }
200224 }
201225 } else {
202- if ( ref && isCurrentlyObserving . current ) {
203- resizeObserverManager . unobserve ( ref )
204- isCurrentlyObserving . current = false
205- }
226+ if ( ref ) unobserveElement ( ref )
206227 setIsShowingChildren ( false )
207228 }
208229 } catch ( error ) {
@@ -212,7 +233,7 @@ export function VirtualElement({
212233 inViewChangeTimerRef . current = undefined
213234 }
214235 } , 100 )
215- } , [ inView , ref , handleResize , resizeObserverManager ] )
236+ } , [ inView , ref , handleResize , resizeObserverManager , unobserveElement ] )
216237
217238 const onVisibleChanged = useCallback (
218239 ( visible : boolean ) => {
@@ -225,38 +246,34 @@ export function VirtualElement({
225246 )
226247
227248 const isScrolling = ( ) : boolean => {
228- // Don't do updates while scrolling:
229- if ( getViewPortScrollingState ( ) . isProgrammaticScrollInProgress ) {
230- return true
231- }
232- // And wait if a programmatic scroll was done recently:
233- const timeSinceLastProgrammaticScroll = Date . now ( ) - getViewPortScrollingState ( ) . lastProgrammaticScrollTime
234- if ( timeSinceLastProgrammaticScroll < 100 ) {
235- return true
236- }
237- return false
249+ const { isProgrammaticScrollInProgress, lastProgrammaticScrollTime } = getViewPortScrollingState ( )
250+ if ( ! isProgrammaticScrollInProgress ) return false
251+
252+ // Safety valve: stale programmatic scroll state should not block virtualization indefinitely.
253+ return Date . now ( ) - lastProgrammaticScrollTime < 1200
238254 }
239255
240256 useEffect ( ( ) => {
241257 // Setup initial observer if element is in view
242258 if ( ref && inView && ! isCurrentlyObserving . current ) {
243259 resizeObserverManager . observe ( ref , handleResize )
260+ observedElementRef . current = ref
244261 isCurrentlyObserving . current = true
245262 }
246263
247264 // Cleanup function
248265 return ( ) => {
249266 // Clean up resize observer
250- if ( ref && isCurrentlyObserving . current ) {
251- resizeObserverManager . unobserve ( ref )
252- isCurrentlyObserving . current = false
267+ if ( ref ) unobserveElement ( ref )
268+ if ( observedElementRef . current && observedElementRef . current !== ref ) {
269+ unobserveElement ( observedElementRef . current )
253270 }
254271
255272 if ( inViewChangeTimerRef . current ) {
256273 clearTimeout ( inViewChangeTimerRef . current )
257274 }
258275 }
259- } , [ ref , inView , handleResize ] )
276+ } , [ ref , inView , handleResize , unobserveElement ] )
260277
261278 useEffect ( ( ) => {
262279 if ( inView === true ) {
@@ -296,6 +313,7 @@ export function VirtualElement({
296313 }
297314 idleCallback = window . requestIdleCallback (
298315 ( ) => {
316+ if ( ! isMountedRef . current ) return
299317 // Measure the entire wrapper element instead of just the childRef
300318 if ( ref ) {
301319 const measurements = measureElement ( ref , placeholderHeight )
@@ -413,6 +431,23 @@ export class ElementObserverManager {
413431 private resizeObserver : ResizeObserver
414432 private mutationObserver : MutationObserver
415433 private observedElements : Map < HTMLElement , ( ) => void >
434+ private isMutationObserverActive = false
435+
436+ private hasConnectedObservedElements ( ) : boolean {
437+ for ( const observedElement of this . observedElements . keys ( ) ) {
438+ if ( document . contains ( observedElement ) ) return true
439+ }
440+ return false
441+ }
442+
443+ private pruneDetachedObservedElements ( ) : void {
444+ for ( const observedElement of Array . from ( this . observedElements . keys ( ) ) ) {
445+ if ( ! document . contains ( observedElement ) ) {
446+ this . observedElements . delete ( observedElement )
447+ this . resizeObserver . unobserve ( observedElement )
448+ }
449+ }
450+ }
416451
417452 private constructor ( ) {
418453 this . observedElements = new Map ( )
@@ -421,39 +456,85 @@ export class ElementObserverManager {
421456 this . resizeObserver = new ResizeObserver ( ( entries ) => {
422457 entries . forEach ( ( entry ) => {
423458 const element = entry . target as HTMLElement
459+ if ( ! document . contains ( element ) ) {
460+ this . observedElements . delete ( element )
461+ this . resizeObserver . unobserve ( element )
462+ return
463+ }
424464 const callback = this . observedElements . get ( element )
425465 if ( callback ) {
426466 callback ( )
427467 }
428468 } )
469+
470+ // Ensure detached entries are aggressively cleaned even without follow-up DOM mutations.
471+ this . pruneDetachedObservedElements ( )
472+ if ( this . observedElements . size === 0 ) {
473+ this . disconnectMutationObserver ( )
474+ }
429475 } )
430476
431- // Configure MutationObserver
477+ // Configure MutationObserver once and only connect/disconnect based on active observed elements.
432478 this . mutationObserver = new MutationObserver ( ( mutations ) => {
479+ if ( this . observedElements . size === 0 ) return
480+
481+ this . pruneDetachedObservedElements ( )
482+ if ( this . observedElements . size === 0 ) {
483+ this . disconnectMutationObserver ( )
484+ return
485+ }
433486 const targets = new Set < HTMLElement > ( )
434487
435488 mutations . forEach ( ( mutation ) => {
436- const target = mutation . target as HTMLElement
437- // Find the closest observed element
438- let element = target
489+ let element : HTMLElement | null = null
490+ if ( mutation . target instanceof HTMLElement ) {
491+ element = mutation . target
492+ } else {
493+ element = mutation . target . parentElement
494+ }
495+
496+ if ( ! element || ! document . contains ( element ) ) return
497+
439498 while ( element ) {
440499 if ( this . observedElements . has ( element ) ) {
441500 targets . add ( element )
442501 break
443502 }
444- if ( ! element . parentElement ) break
445503 element = element . parentElement
446504 }
447505 } )
448506
449- // Call callbacks for affected elements
450507 targets . forEach ( ( element ) => {
508+ if ( ! document . contains ( element ) ) {
509+ this . observedElements . delete ( element )
510+ this . resizeObserver . unobserve ( element )
511+ return
512+ }
451513 const callback = this . observedElements . get ( element )
452514 if ( callback ) callback ( )
453515 } )
454516 } )
455517 }
456518
519+ private ensureMutationObserverConnected ( ) : void {
520+ if ( this . isMutationObserverActive ) return
521+ if ( this . observedElements . size === 0 ) return
522+ if ( ! this . hasConnectedObservedElements ( ) ) return
523+ if ( ! document . body ) return
524+
525+ this . mutationObserver . observe ( document . body , {
526+ childList : true ,
527+ subtree : true ,
528+ } )
529+ this . isMutationObserverActive = true
530+ }
531+
532+ private disconnectMutationObserver ( ) : void {
533+ if ( ! this . isMutationObserverActive ) return
534+ this . mutationObserver . disconnect ( )
535+ this . isMutationObserverActive = false
536+ }
537+
457538 public static getInstance ( ) : ElementObserverManager {
458539 if ( ! ElementObserverManager . instance ) {
459540 ElementObserverManager . instance = new ElementObserverManager ( )
@@ -463,31 +544,31 @@ export class ElementObserverManager {
463544
464545 public observe ( element : HTMLElement , callback : ( ) => void ) : void {
465546 if ( ! element ) return
547+ if ( ! document . contains ( element ) ) return
548+ this . pruneDetachedObservedElements ( )
466549
467550 this . observedElements . set ( element , callback )
468551 this . resizeObserver . observe ( element )
469- this . mutationObserver . observe ( element , {
470- childList : true ,
471- subtree : true ,
472- attributes : true ,
473- characterData : true ,
474- } )
552+ this . ensureMutationObserverConnected ( )
475553 }
476554
477555 public unobserve ( element : HTMLElement ) : void {
478556 if ( ! element ) return
479557 this . observedElements . delete ( element )
480558 this . resizeObserver . unobserve ( element )
559+ this . pruneDetachedObservedElements ( )
481560
482- // Disconnect and reconnect mutation observer to refresh the list of observed elements
483- this . mutationObserver . disconnect ( )
484- this . observedElements . forEach ( ( _ , el ) => {
485- this . mutationObserver . observe ( el , {
486- childList : true ,
487- subtree : true ,
488- attributes : true ,
489- characterData : true ,
490- } )
491- } )
561+ if ( this . observedElements . size === 0 ) {
562+ this . resizeObserver . disconnect ( )
563+ this . disconnectMutationObserver ( )
564+ return
565+ }
566+
567+ if ( ! this . hasConnectedObservedElements ( ) ) {
568+ this . disconnectMutationObserver ( )
569+ return
570+ }
571+
572+ this . ensureMutationObserverConnected ( )
492573 }
493574}
0 commit comments