|
1 | 1 | import { Meteor } from 'meteor/meteor' |
2 | | -import React, { useContext, useMemo } from 'react' |
| 2 | +import React, { useContext, useEffect, useMemo } from 'react' |
3 | 3 | import { ParsedQuery, parse as queryStringParse } from 'query-string' |
4 | 4 | import { Translated, translateWithTracker, useTracker } from '../lib/ReactMeteorData/react-meteor-data.js' |
5 | 5 | import { VTContent, NoteSeverity, ISourceLayer } from '@sofie-automation/blueprints-integration' |
@@ -284,6 +284,361 @@ export function RundownView(props: Readonly<IProps>): JSX.Element { |
284 | 284 | partInstances?.currentPartInstance |
285 | 285 | ) |
286 | 286 |
|
| 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 | + |
287 | 642 | return ( |
288 | 643 | <div className="container-fluid header-clear"> |
289 | 644 | <RundownViewContent |
|
0 commit comments