Skip to content

Commit 40d0598

Browse files
committed
fix: added service clean up detached nodes - add description
1 parent f8c4734 commit 40d0598

File tree

1 file changed

+356
-1
lines changed

1 file changed

+356
-1
lines changed

packages/webui/src/client/ui/RundownView.tsx

Lines changed: 356 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,5 @@
11
import { Meteor } from 'meteor/meteor'
2-
import React, { useContext, useMemo } from 'react'
2+
import React, { useContext, useEffect, useMemo } from 'react'
33
import { ParsedQuery, parse as queryStringParse } from 'query-string'
44
import { Translated, translateWithTracker, useTracker } from '../lib/ReactMeteorData/react-meteor-data.js'
55
import { VTContent, NoteSeverity, ISourceLayer } from '@sofie-automation/blueprints-integration'
@@ -284,6 +284,361 @@ export function RundownView(props: Readonly<IProps>): JSX.Element {
284284
partInstances?.currentPartInstance
285285
)
286286

287+
// This is a hack, as the VirtualElement is not releasing its memory properly:
288+
useEffect(() => {
289+
const detachedNodes = new Set<Element>()
290+
291+
// Set up observer to catch nodes when they get removed
292+
const observer = new MutationObserver((mutations) => {
293+
mutations.forEach((mutation) => {
294+
mutation.removedNodes.forEach((node) => {
295+
if (node instanceof Element) {
296+
detachedNodes.add(node)
297+
}
298+
})
299+
})
300+
})
301+
302+
observer.observe(document.body, {
303+
childList: true,
304+
subtree: true,
305+
})
306+
307+
function cleanupDetachedNodesFromMemory(): number {
308+
let removedCount = 0
309+
// Delay the cleanup to allow stabilize after mutations:
310+
setTimeout(() => {
311+
detachedNodes.forEach((node) => {
312+
if (!document.contains(node)) {
313+
// Recursively clean up the entire subtree
314+
cleanupNodeAndChildren(node)
315+
detachedNodes.delete(node)
316+
removedCount++
317+
}
318+
})
319+
}, 100)
320+
321+
return removedCount
322+
}
323+
324+
function cleanupNodeAndChildren(node: any): void {
325+
// Recursively clean up all children:
326+
if (node.children && node.children.length > 0) {
327+
Array.from(node.children).forEach((child: any) => {
328+
cleanupNodeAndChildren(child)
329+
})
330+
}
331+
332+
// Also handle text nodes and other node types
333+
if (node.childNodes && node.childNodes.length > 0) {
334+
Array.from(node.childNodes).forEach((child: any) => {
335+
if (child.nodeType === Node.ELEMENT_NODE) {
336+
cleanupNodeAndChildren(child)
337+
}
338+
// Clear text nodes and other node types
339+
clearNodeReferences(child)
340+
})
341+
}
342+
343+
// Clear the current node
344+
clearNodeReferences(node)
345+
}
346+
347+
function clearNodeReferences(node: any): void {
348+
if (!node) return
349+
350+
try {
351+
if (node._reactRootContainer) {
352+
// This is a React root, skip it as it's still active
353+
return
354+
}
355+
356+
// First, recursively destroy all children
357+
if (node.childNodes) {
358+
Array.from(node.childNodes).forEach((child: any) => {
359+
clearNodeReferences(child)
360+
})
361+
}
362+
363+
// Force remove from parent if still attached
364+
if (node.parentNode && !document.contains(node)) {
365+
node.parentNode.removeChild(node)
366+
}
367+
368+
// Enhanced canvas cleanup - clear any stored references
369+
if (node.tagName === 'CANVAS') {
370+
const canvas = node as HTMLCanvasElement
371+
// Clear any custom properties that might hold references
372+
Object.keys(canvas).forEach((key) => {
373+
if (!key.startsWith('__react') && typeof (canvas as any)[key] === 'object') {
374+
//@ts-expect-error node[key] = null
375+
canvas[key] = null
376+
}
377+
})
378+
}
379+
380+
// More aggressive React Context Menu cleanup
381+
if (node.classList?.contains('react-contextmenu-wrapper')) {
382+
// Remove from any global registry
383+
const menuId =
384+
node.getAttribute('data-menu-id') || node.querySelector('[data-menu-id]')?.getAttribute('data-menu-id')
385+
if (menuId && (window as any).__reactContextMenus) {
386+
delete (window as any).__reactContextMenus[menuId]
387+
}
388+
// React context menu components often have special cleanup needs
389+
try {
390+
if (node._reactInternalFiber) {
391+
node._reactInternalFiber = null
392+
}
393+
if (node.__reactEventHandlers) {
394+
node.__reactEventHandlers = null
395+
}
396+
} catch (e) {
397+
// Ignore cleanup errors
398+
console.warn('Failed to clean up React context menu:', e)
399+
}
400+
}
401+
402+
// Clear any transform or style-based animations that might hold references
403+
if (node.style) {
404+
node.style.transform = ''
405+
node.style.transition = ''
406+
node.style.animation = ''
407+
}
408+
409+
// Break all React references
410+
const reactKeys = Object.keys(node).filter(
411+
(key) =>
412+
key.startsWith('__react') ||
413+
key.startsWith('_react') ||
414+
key.includes('react') ||
415+
key.includes('React') ||
416+
key.startsWith('__fiber') ||
417+
key === '_owner' ||
418+
key === '_store'
419+
)
420+
421+
reactKeys.forEach((key) => {
422+
try {
423+
const value = node[key]
424+
// Clear any circular references in React objects
425+
if (value && typeof value === 'object') {
426+
if (value.stateNode === node) {
427+
value.stateNode = null
428+
}
429+
if (value.child) value.child = null
430+
if (value.sibling) value.sibling = null
431+
if (value.return) value.return = null
432+
}
433+
node[key] = null
434+
delete node[key]
435+
} catch (e) {
436+
// Some properties might be non-configurable
437+
console.warn(`Failed to clear React property ${key} on node:`, e)
438+
}
439+
})
440+
441+
// Remove all event listeners
442+
if (node.removeEventListener) {
443+
// Common React event types
444+
// Add missing ones that we use but not on the list:
445+
446+
const eventTypes = [
447+
'click',
448+
'mousedown',
449+
'mouseup',
450+
'mouseover',
451+
'mouseout',
452+
'focus',
453+
'blur',
454+
'change',
455+
'input',
456+
'keydown',
457+
'keyup',
458+
'touchstart',
459+
'touchend',
460+
'touchmove',
461+
]
462+
463+
eventTypes.forEach((eventType) => {
464+
try {
465+
// Remove all listeners:
466+
const listeners = node.getEventListeners?.(eventType) || []
467+
listeners.forEach((listener: any) => {
468+
node.removeEventListener(eventType, listener.listener)
469+
})
470+
} catch (e) {
471+
// getEventListeners might not be available
472+
console.warn(`Failed to remove event listeners for ${eventType}:`, e)
473+
}
474+
})
475+
}
476+
477+
// Add this section for ARIA cleanup
478+
const ariaReferenceAttrs = [
479+
'aria-labelledby',
480+
'aria-describedby',
481+
'aria-controls',
482+
'aria-owns',
483+
'aria-flowto',
484+
'aria-activedescendant',
485+
]
486+
487+
ariaReferenceAttrs.forEach((attr) => {
488+
if (node.hasAttribute?.(attr)) {
489+
const referencedIds = node.getAttribute(attr)?.split(' ') || []
490+
referencedIds.forEach((id: any) => {
491+
// Clear any cached references to these elements
492+
const referencedEl = document.getElementById(id)
493+
if (referencedEl && referencedEl !== node) {
494+
// Clear back-references if any
495+
Object.keys(referencedEl as any).forEach((key) => {
496+
if ((referencedEl as any)[key] === node) {
497+
delete (referencedEl as any)[key]
498+
}
499+
})
500+
}
501+
})
502+
node.removeAttribute(attr)
503+
}
504+
})
505+
506+
// Enhanced canvas cleanup
507+
if (node.tagName === 'CANVAS') {
508+
const canvas = node as HTMLCanvasElement
509+
// Get all possible contexts
510+
;['2d', 'webgl', 'webgl2', 'bitmaprenderer'].forEach((contextType) => {
511+
try {
512+
const ctx = canvas.getContext(contextType as any)
513+
if (ctx) {
514+
if (contextType === '2d' && ctx) {
515+
;(ctx as CanvasRenderingContext2D).clearRect(0, 0, canvas.width, canvas.height)
516+
// Reset transform and clear path
517+
;(ctx as CanvasRenderingContext2D).setTransform(1, 0, 0, 1, 0, 0)
518+
;(ctx as CanvasRenderingContext2D).beginPath()
519+
}
520+
// Clear the context reference
521+
if ('loseContext' in ctx) {
522+
;(ctx as any).loseContext()
523+
}
524+
}
525+
} catch (e) {
526+
// Some contexts might not be available or supported
527+
console.warn(`Failed to clear canvas context ${contextType}:`, e)
528+
}
529+
})
530+
// Clear size to free memory
531+
canvas.width = 0
532+
canvas.height = 0
533+
}
534+
535+
// Clear React Context Menu global listeners
536+
if (node.classList?.contains('react-contextmenu-wrapper')) {
537+
// React context menu often uses global document listeners
538+
if ((window as any).ReactContextMenu) {
539+
try {
540+
;(window as any).ReactContextMenu.hideAll()
541+
} catch (e) {
542+
// If ReactContextMenu is not available, ignore
543+
console.warn('Failed to hide React Context Menu:', e)
544+
}
545+
}
546+
547+
// Clear any menu state
548+
node.classList.remove('react-contextmenu-wrapper--visible')
549+
node.classList.remove('react-contextmenu-wrapper--active')
550+
}
551+
552+
// Clear focus/blur related registrations
553+
if (node.tabIndex !== undefined && node.tabIndex >= 0) {
554+
node.tabIndex = -1
555+
node.blur?.()
556+
}
557+
558+
// Clear custom event listeners that might be using delegation
559+
const customDataAttrs = [
560+
'data-obj-id',
561+
'data-part-id',
562+
'data-layer-id',
563+
'data-source-id',
564+
'data-output-id',
565+
'data-identifier',
566+
]
567+
customDataAttrs.forEach((attr) => {
568+
if (node.hasAttribute?.(attr)) {
569+
const value = node.getAttribute(attr)
570+
// These might be used as keys in event delegation maps
571+
node.removeAttribute(attr)
572+
573+
// Check for global event delegation systems
574+
if ((window as any).__eventDelegation) {
575+
delete (window as any).__eventDelegation[value]
576+
}
577+
}
578+
})
579+
580+
// Clear intersection/resize/mutation observers
581+
if ((window as any).IntersectionObserver) {
582+
// Some libraries attach observer instances to elements
583+
const observerKeys = Object.keys(node).filter((key) => key.includes('observer') || key.includes('Observer'))
584+
observerKeys.forEach((key) => {
585+
try {
586+
if (node[key] && typeof node[key].disconnect === 'function') {
587+
node[key].disconnect()
588+
}
589+
delete node[key]
590+
} catch (e) {
591+
// Ignore errors if the property is not a valid observer
592+
console.warn(`Failed to disconnect observer ${key}:`, e)
593+
}
594+
})
595+
}
596+
597+
// Clear any animation frame callbacks
598+
if (node.__rafId) {
599+
cancelAnimationFrame(node.__rafId)
600+
delete node.__rafId
601+
}
602+
603+
// Clear any pending timers stored on the element
604+
;['__timeoutId', '__intervalId', '_timer', '_timeout'].forEach((prop) => {
605+
if (node[prop]) {
606+
clearTimeout(node[prop])
607+
clearInterval(node[prop])
608+
delete node[prop]
609+
}
610+
})
611+
612+
// Clear any jQuery data if present
613+
if (typeof window !== 'undefined' && (window as any).jQuery) {
614+
try {
615+
;(window as any).jQuery(node).off()
616+
;(window as any).jQuery.removeData(node)
617+
} catch (e) {
618+
// If jQuery is not available or node is not a jQuery object, ignore
619+
console.warn('Failed to clear jQuery data:', e)
620+
}
621+
}
622+
623+
// Don't clear innerHTML for React nodes - it can break cleanup
624+
// Instead, remove child nodes properly
625+
while (node.firstChild) {
626+
node.removeChild(node.firstChild)
627+
}
628+
} catch (error) {
629+
// Continue cleanup even if individual operations fail
630+
console.warn('Error during node cleanup:', error)
631+
}
632+
}
633+
634+
setInterval(
635+
() => {
636+
cleanupDetachedNodesFromMemory()
637+
},
638+
1 * 60 * 1000
639+
)
640+
}, [])
641+
287642
return (
288643
<div className="container-fluid header-clear">
289644
<RundownViewContent

0 commit comments

Comments
 (0)