Skip to content

Commit fe66f7f

Browse files
committed
fix(webui): Fix more memory leaks [copilot]
1 parent 3826017 commit fe66f7f

31 files changed

Lines changed: 828 additions & 231 deletions

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

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -40,6 +40,7 @@ function usePortal(id: string, style?: Partial<Record<keyof React.CSSProperties,
4040
const existingParent = document.querySelector<HTMLElement>(`#${id}`)
4141
// Parent is either a new root or the existing dom element
4242
const parentElem = existingParent || createRootElement(id)
43+
const parentCreatedByThisHook = !existingParent
4344

4445
// If there is no existing DOM element, add a new one.
4546
if (!existingParent) {
@@ -53,7 +54,8 @@ function usePortal(id: string, style?: Partial<Record<keyof React.CSSProperties,
5354

5455
return function removeElement() {
5556
rootElemRef.current?.remove()
56-
if (!parentElem.childElementCount) {
57+
rootElemRef.current = null
58+
if (parentCreatedByThisHook && !parentElem.childElementCount) {
5759
parentElem.remove()
5860
}
5961
}

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

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -42,6 +42,11 @@ export class SplitDropdown extends React.Component<IProps, IState> {
4242
}
4343
}
4444

45+
componentWillUnmount(): void {
46+
this._popperRef = null
47+
this._popperUpdate = undefined
48+
}
49+
4550
private toggleExpco = async (e: React.MouseEvent<HTMLElement>) => {
4651
e.preventDefault()
4752
e.stopPropagation()

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

Lines changed: 129 additions & 48 deletions
Original file line numberDiff line numberDiff line change
@@ -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

Comments
 (0)