Skip to content

Commit 45e8a7b

Browse files
committed
Merge branch 'fix-memory-leaks' into bbc-main-autonext
2 parents 7261c5a + b5b4be2 commit 45e8a7b

2 files changed

Lines changed: 63 additions & 58 deletions

File tree

packages/webui/src/client/lib/VirtualElement.tsx

Lines changed: 40 additions & 57 deletions
Original file line numberDiff line numberDiff line change
@@ -234,16 +234,11 @@ export function VirtualElement({
234234
)
235235

236236
const isScrolling = (): boolean => {
237-
// Don't do updates while scrolling:
238-
if (getViewPortScrollingState().isProgrammaticScrollInProgress) {
239-
return true
240-
}
241-
// And wait if a programmatic scroll was done recently:
242-
const timeSinceLastProgrammaticScroll = Date.now() - getViewPortScrollingState().lastProgrammaticScrollTime
243-
if (timeSinceLastProgrammaticScroll < 100) {
244-
return true
245-
}
246-
return false
237+
const { isProgrammaticScrollInProgress, lastProgrammaticScrollTime } = getViewPortScrollingState()
238+
if (!isProgrammaticScrollInProgress) return false
239+
240+
// Safety valve: stale programmatic scroll state should not block virtualization indefinitely.
241+
return Date.now() - lastProgrammaticScrollTime < 1200
247242
}
248243

249244
useEffect(() => {
@@ -423,7 +418,7 @@ export class ElementObserverManager {
423418
private resizeObserver: ResizeObserver
424419
private mutationObserver: MutationObserver
425420
private observedElements: Map<HTMLElement, () => void>
426-
private pendingReconnectFrame: number | undefined
421+
private isMutationObserverActive = false
427422

428423
private pruneDetachedObservedElements(): void {
429424
for (const observedElement of Array.from(this.observedElements.keys())) {
@@ -448,34 +443,59 @@ export class ElementObserverManager {
448443
})
449444
})
450445

451-
// Configure MutationObserver
446+
// Configure MutationObserver once and only connect/disconnect based on active observed elements.
452447
this.mutationObserver = new MutationObserver((mutations) => {
448+
if (this.observedElements.size === 0) return
449+
453450
this.pruneDetachedObservedElements()
454451
const targets = new Set<HTMLElement>()
455452

456453
mutations.forEach((mutation) => {
457-
const target = mutation.target as HTMLElement
458-
if (!document.contains(target)) return
459-
// Find the closest observed element
460-
let element = target
454+
let element: HTMLElement | null = null
455+
if (mutation.target instanceof HTMLElement) {
456+
element = mutation.target
457+
} else {
458+
element = mutation.target.parentElement
459+
}
460+
461+
if (!element || !document.contains(element)) return
462+
461463
while (element) {
462464
if (this.observedElements.has(element)) {
463465
targets.add(element)
464466
break
465467
}
466-
if (!element.parentElement) break
467468
element = element.parentElement
468469
}
469470
})
470471

471-
// Call callbacks for affected elements
472472
targets.forEach((element) => {
473473
const callback = this.observedElements.get(element)
474474
if (callback) callback()
475475
})
476476
})
477477
}
478478

479+
private ensureMutationObserverConnected(): void {
480+
if (this.isMutationObserverActive) return
481+
if (this.observedElements.size === 0) return
482+
if (!document.body) return
483+
484+
this.mutationObserver.observe(document.body, {
485+
childList: true,
486+
subtree: true,
487+
attributes: true,
488+
characterData: true,
489+
})
490+
this.isMutationObserverActive = true
491+
}
492+
493+
private disconnectMutationObserver(): void {
494+
if (!this.isMutationObserverActive) return
495+
this.mutationObserver.disconnect()
496+
this.isMutationObserverActive = false
497+
}
498+
479499
public static getInstance(): ElementObserverManager {
480500
if (!ElementObserverManager.instance) {
481501
ElementObserverManager.instance = new ElementObserverManager()
@@ -490,55 +510,18 @@ export class ElementObserverManager {
490510

491511
this.observedElements.set(element, callback)
492512
this.resizeObserver.observe(element)
493-
if (!this.pendingReconnectFrame) {
494-
this.mutationObserver.observe(element, {
495-
childList: true,
496-
subtree: true,
497-
attributes: true,
498-
characterData: true,
499-
})
500-
}
513+
this.ensureMutationObserverConnected()
501514
}
502515

503516
public unobserve(element: HTMLElement): void {
504517
if (!element) return
505518
this.observedElements.delete(element)
506519
this.resizeObserver.unobserve(element)
507520
this.pruneDetachedObservedElements()
508-
this.mutationObserver.disconnect()
509521

510522
if (this.observedElements.size === 0) {
511-
if (this.pendingReconnectFrame) {
512-
window.cancelAnimationFrame(this.pendingReconnectFrame)
513-
this.pendingReconnectFrame = undefined
514-
}
515-
this.mutationObserver.disconnect()
516523
this.resizeObserver.disconnect()
517-
return
518-
}
519-
520-
if (!this.pendingReconnectFrame) {
521-
this.pendingReconnectFrame = window.requestAnimationFrame(() => {
522-
this.pendingReconnectFrame = undefined
523-
524-
// MutationObserver has no per-element unobserve, so we reconnect once per frame.
525-
this.pruneDetachedObservedElements()
526-
527-
if (this.observedElements.size === 0) {
528-
this.resizeObserver.disconnect()
529-
return
530-
}
531-
532-
this.observedElements.forEach((_, el) => {
533-
if (!document.contains(el)) return
534-
this.mutationObserver.observe(el, {
535-
childList: true,
536-
subtree: true,
537-
attributes: true,
538-
characterData: true,
539-
})
540-
})
541-
})
524+
this.disconnectMutationObserver()
542525
}
543526
}
544527
}

packages/webui/src/client/lib/viewPort.ts

Lines changed: 23 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -337,8 +337,30 @@ export async function scrollToPosition(scrollPosition: number, noAnimation?: boo
337337
left: 0,
338338
behavior: 'smooth',
339339
})
340-
await new Promise((resolve) => setTimeout(resolve, 300))
340+
341+
await new Promise<void>((resolve) => {
342+
const startTime = Date.now()
343+
let previousY = window.scrollY
344+
let stableTicks = 0
345+
const interval = window.setInterval(() => {
346+
const currentY = window.scrollY
347+
if (Math.abs(currentY - previousY) <= 1) {
348+
stableTicks++
349+
} else {
350+
stableTicks = 0
351+
}
352+
previousY = currentY
353+
viewPortScrollingState.lastProgrammaticScrollTime = Date.now()
354+
355+
if (stableTicks >= 2 || Date.now() - startTime > 1200) {
356+
window.clearInterval(interval)
357+
resolve()
358+
}
359+
}, 50)
360+
})
361+
341362
viewPortScrollingState.isProgrammaticScrollInProgress = false
363+
viewPortScrollingState.lastProgrammaticScrollTime = Date.now()
342364
}
343365
}
344366

0 commit comments

Comments
 (0)