Skip to content

Commit ff9a904

Browse files
committed
fix: added service clean up detached nodes
1 parent bd324b1 commit ff9a904

File tree

1 file changed

+355
-1
lines changed

1 file changed

+355
-1
lines changed

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

Lines changed: 355 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,360 @@ export function RundownView(props: Readonly<IProps>): JSX.Element {
284284
partInstances?.currentPartInstance
285285
)
286286

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

0 commit comments

Comments
 (0)