From 99fd9945bb190044604c28c5b26463e890ced822 Mon Sep 17 00:00:00 2001 From: Peter C <12292660+PeterC89@users.noreply.github.com> Date: Fri, 5 Jun 2026 00:01:54 +0100 Subject: [PATCH 1/8] fix(webui): Fix more memory leaks [copilot] --- packages/webui/src/client/lib/Escape.tsx | 4 +- .../webui/src/client/lib/VirtualElement.tsx | 166 ++++++++++++++---- packages/webui/src/client/lib/viewPort.ts | 41 +++++ .../ui/ClockView/CameraScreen/index.tsx | 16 +- .../client/ui/PreviewPopUp/PreviewPopUp.tsx | 29 ++- .../ui/PreviewPopUp/PreviewPopUpContext.tsx | 82 ++++++++- packages/webui/src/client/ui/RundownView.tsx | 57 +++++- .../SegmentAdlibTesting.tsx | 51 +++--- .../LinePartMainPiece/LinePartMainPiece.tsx | 18 +- .../LinePartAdLibIndicator.tsx | 14 +- .../LinePartIndicator.tsx | 7 +- .../LinePartScriptPiece.tsx | 18 +- .../PieceIndicatorMenu.tsx | 6 +- .../LinePartSecondaryPiece.tsx | 18 +- .../src/client/ui/SegmentList/SegmentList.tsx | 13 +- .../SegmentStoryboard/SegmentStoryboard.tsx | 51 +++--- .../StoryboardSecondaryPiece.tsx | 18 +- .../StoryboardPartThumbnailInner.tsx | 18 +- .../Parts/InvalidPartCover.tsx | 18 +- .../Parts/SegmentTimelinePart.tsx | 1 - .../Renderers/MicSourceRenderer.tsx | 50 ++++-- .../Renderers/VTSourceRenderer.tsx | 137 +++++++++------ .../ui/SegmentTimeline/SegmentTimeline.tsx | 21 ++- .../SegmentTimelineContainer.tsx | 36 +++- .../ui/SegmentTimeline/SourceLayerItem.tsx | 35 +++- .../ui/SegmentTimeline/TimelineGrid.tsx | 4 + .../usePreviewPopUpSession.ts | 7 +- .../Shelf/Renderers/L3rdListItemRenderer.tsx | 21 ++- .../ui/Shelf/Renderers/VTListItemRenderer.tsx | 15 +- .../src/client/ui/util/useSetDocumentClass.ts | 35 +++- 30 files changed, 790 insertions(+), 217 deletions(-) diff --git a/packages/webui/src/client/lib/Escape.tsx b/packages/webui/src/client/lib/Escape.tsx index 65c92206ed1..8d2b8a911ae 100644 --- a/packages/webui/src/client/lib/Escape.tsx +++ b/packages/webui/src/client/lib/Escape.tsx @@ -40,6 +40,7 @@ function usePortal(id: string, style?: Partial(`#${id}`) // Parent is either a new root or the existing dom element const parentElem = existingParent || createRootElement(id) + const parentCreatedByThisHook = !existingParent // If there is no existing DOM element, add a new one. if (!existingParent) { @@ -53,7 +54,8 @@ function usePortal(id: string, style?: Partial(null) + const isMountedRef = useRef(true) + + useEffect(() => { + return () => { + isMountedRef.current = false + } + }, []) + + const placeholderHeightPx = measurements?.clientHeight ?? placeholderHeight ?? ref?.clientHeight ?? 0 const styleObj = useMemo( () => ({ width: width ?? 'auto', - height: ((placeholderHeight || ref?.clientHeight) ?? '0') + 'px', + height: `${placeholderHeightPx}px`, // These properties are used to ensure that if a prior element is changed from - // placeHolder to element, the position of visible elements are not affected. + // placeholder to element, the position of visible elements are not affected. contentVisibility: 'auto', - containIntrinsicSize: `0 ${(placeholderHeight || ref?.clientHeight) ?? '0'}px`, + containIntrinsicSize: `0 ${placeholderHeightPx}px`, contain: 'size layout', }), - [width, placeholderHeight] + [width, placeholderHeightPx] ) const handleResize = useCallback(() => { + if (!isMountedRef.current) return if (ref) { // Show children during measurement setIsShowingChildren(true) requestAnimationFrame(() => { + if (!isMountedRef.current) return const measurements = measureElement(ref, placeholderHeight) if (measurements) { setMeasurements(measurements) @@ -111,6 +123,18 @@ export function VirtualElement({ } }, [ref, inView, placeholderHeight]) + const unobserveElement = useCallback( + (element: HTMLDivElement | null) => { + if (!element) return + resizeObserverManager.unobserve(element) + if (observedElementRef.current === element) { + observedElementRef.current = null + } + isCurrentlyObserving.current = false + }, + [resizeObserverManager] + ) + // failsafe to ensure visible elements if resizing happens while scrolling useEffect(() => { if (!isShowingChildren) { @@ -165,6 +189,10 @@ export function VirtualElement({ }, [inView, isShowingChildren]) useEffect(() => { + if (observedElementRef.current && observedElementRef.current !== ref) { + unobserveElement(observedElementRef.current) + } + if (inView) { setIsShowingChildren(true) } @@ -195,14 +223,12 @@ export function VirtualElement({ if (ref) { if (!isCurrentlyObserving.current) { resizeObserverManager.observe(ref, handleResize) + observedElementRef.current = ref isCurrentlyObserving.current = true } } } else { - if (ref && isCurrentlyObserving.current) { - resizeObserverManager.unobserve(ref) - isCurrentlyObserving.current = false - } + if (ref) unobserveElement(ref) setIsShowingChildren(false) } } catch (error) { @@ -212,7 +238,7 @@ export function VirtualElement({ inViewChangeTimerRef.current = undefined } }, 100) - }, [inView, ref, handleResize, resizeObserverManager]) + }, [inView, ref, handleResize, resizeObserverManager, unobserveElement]) const onVisibleChanged = useCallback( (visible: boolean) => { @@ -225,12 +251,13 @@ export function VirtualElement({ ) const isScrolling = (): boolean => { + const { isProgrammaticScrollInProgress, lastProgrammaticScrollTime } = getViewPortScrollingState() // Don't do updates while scrolling: - if (getViewPortScrollingState().isProgrammaticScrollInProgress) { + if (isProgrammaticScrollInProgress) { return true } // And wait if a programmatic scroll was done recently: - const timeSinceLastProgrammaticScroll = Date.now() - getViewPortScrollingState().lastProgrammaticScrollTime + const timeSinceLastProgrammaticScroll = Date.now() - lastProgrammaticScrollTime if (timeSinceLastProgrammaticScroll < 100) { return true } @@ -241,22 +268,23 @@ export function VirtualElement({ // Setup initial observer if element is in view if (ref && inView && !isCurrentlyObserving.current) { resizeObserverManager.observe(ref, handleResize) + observedElementRef.current = ref isCurrentlyObserving.current = true } // Cleanup function return () => { // Clean up resize observer - if (ref && isCurrentlyObserving.current) { - resizeObserverManager.unobserve(ref) - isCurrentlyObserving.current = false + if (ref) unobserveElement(ref) + if (observedElementRef.current && observedElementRef.current !== ref) { + unobserveElement(observedElementRef.current) } if (inViewChangeTimerRef.current) { clearTimeout(inViewChangeTimerRef.current) } } - }, [ref, inView, handleResize]) + }, [ref, inView, handleResize, unobserveElement]) useEffect(() => { if (inView === true) { @@ -296,6 +324,7 @@ export function VirtualElement({ } idleCallback = window.requestIdleCallback( () => { + if (!isMountedRef.current) return // Measure the entire wrapper element instead of just the childRef if (ref) { const measurements = measureElement(ref, placeholderHeight) @@ -413,6 +442,23 @@ export class ElementObserverManager { private resizeObserver: ResizeObserver private mutationObserver: MutationObserver private observedElements: Map void> + private isMutationObserverActive = false + + private hasConnectedObservedElements(): boolean { + for (const observedElement of this.observedElements.keys()) { + if (document.contains(observedElement)) return true + } + return false + } + + private pruneDetachedObservedElements(): void { + for (const observedElement of Array.from(this.observedElements.keys())) { + if (!document.contains(observedElement)) { + this.observedElements.delete(observedElement) + this.resizeObserver.unobserve(observedElement) + } + } + } private constructor() { this.observedElements = new Map() @@ -421,39 +467,85 @@ export class ElementObserverManager { this.resizeObserver = new ResizeObserver((entries) => { entries.forEach((entry) => { const element = entry.target as HTMLElement + if (!document.contains(element)) { + this.observedElements.delete(element) + this.resizeObserver.unobserve(element) + return + } const callback = this.observedElements.get(element) if (callback) { callback() } }) + + // Ensure detached entries are aggressively cleaned even without follow-up DOM mutations. + this.pruneDetachedObservedElements() + if (this.observedElements.size === 0) { + this.disconnectMutationObserver() + } }) - // Configure MutationObserver + // Configure MutationObserver once and only connect/disconnect based on active observed elements. this.mutationObserver = new MutationObserver((mutations) => { + if (this.observedElements.size === 0) return + + this.pruneDetachedObservedElements() + if (this.observedElements.size === 0) { + this.disconnectMutationObserver() + return + } const targets = new Set() mutations.forEach((mutation) => { - const target = mutation.target as HTMLElement - // Find the closest observed element - let element = target + let element: HTMLElement | null = null + if (mutation.target instanceof HTMLElement) { + element = mutation.target + } else { + element = mutation.target.parentElement + } + + if (!element || !document.contains(element)) return + while (element) { if (this.observedElements.has(element)) { targets.add(element) break } - if (!element.parentElement) break element = element.parentElement } }) - // Call callbacks for affected elements targets.forEach((element) => { + if (!document.contains(element)) { + this.observedElements.delete(element) + this.resizeObserver.unobserve(element) + return + } const callback = this.observedElements.get(element) if (callback) callback() }) }) } + private ensureMutationObserverConnected(): void { + if (this.isMutationObserverActive) return + if (this.observedElements.size === 0) return + if (!this.hasConnectedObservedElements()) return + if (!document.body) return + + this.mutationObserver.observe(document.body, { + childList: true, + subtree: true, + }) + this.isMutationObserverActive = true + } + + private disconnectMutationObserver(): void { + if (!this.isMutationObserverActive) return + this.mutationObserver.disconnect() + this.isMutationObserverActive = false + } + public static getInstance(): ElementObserverManager { if (!ElementObserverManager.instance) { ElementObserverManager.instance = new ElementObserverManager() @@ -463,31 +555,31 @@ export class ElementObserverManager { public observe(element: HTMLElement, callback: () => void): void { if (!element) return + if (!document.contains(element)) return + this.pruneDetachedObservedElements() this.observedElements.set(element, callback) this.resizeObserver.observe(element) - this.mutationObserver.observe(element, { - childList: true, - subtree: true, - attributes: true, - characterData: true, - }) + this.ensureMutationObserverConnected() } public unobserve(element: HTMLElement): void { if (!element) return this.observedElements.delete(element) this.resizeObserver.unobserve(element) + this.pruneDetachedObservedElements() - // Disconnect and reconnect mutation observer to refresh the list of observed elements - this.mutationObserver.disconnect() - this.observedElements.forEach((_, el) => { - this.mutationObserver.observe(el, { - childList: true, - subtree: true, - attributes: true, - characterData: true, - }) - }) + if (this.observedElements.size === 0) { + this.resizeObserver.disconnect() + this.disconnectMutationObserver() + return + } + + if (!this.hasConnectedObservedElements()) { + this.disconnectMutationObserver() + return + } + + this.ensureMutationObserverConnected() } } diff --git a/packages/webui/src/client/lib/viewPort.ts b/packages/webui/src/client/lib/viewPort.ts index 72d3a089191..d9c03eb8dfa 100644 --- a/packages/webui/src/client/lib/viewPort.ts +++ b/packages/webui/src/client/lib/viewPort.ts @@ -22,6 +22,20 @@ const viewPortScrollingState = { lastProgrammaticScrollTime: 0, } +function clearPendingScrollState(): void { + if (pendingFirstStageTimeout) { + clearTimeout(pendingFirstStageTimeout) + pendingFirstStageTimeout = undefined + } + + if (pendingSecondStageScroll) { + window.cancelIdleCallback(pendingSecondStageScroll) + pendingSecondStageScroll = undefined + } + + currentScrollingElement = undefined +} + export function getViewPortScrollingState(): { isProgrammaticScrollInProgress: boolean lastProgrammaticScrollTime: number @@ -36,11 +50,14 @@ export function maintainFocusOnPartInstance( noAnimation?: boolean ): void { focusState.startTime = Date.now() + viewPortScrollingState.isProgrammaticScrollInProgress = true + viewPortScrollingState.lastProgrammaticScrollTime = Date.now() const focus = async () => { // Only proceed if we're not already scrolling and within the time window if (!focusState.isScrolling && Date.now() - focusState.startTime < timeWindow) { focusState.isScrolling = true + viewPortScrollingState.lastProgrammaticScrollTime = Date.now() try { await scrollToPartInstance(partInstanceId, forceScroll, noAnimation) @@ -48,6 +65,7 @@ export function maintainFocusOnPartInstance( // Handle error if needed } finally { focusState.isScrolling = false + viewPortScrollingState.lastProgrammaticScrollTime = Date.now() } } else if (Date.now() - focusState.startTime >= timeWindow) { quitFocusOnPart() @@ -91,6 +109,24 @@ function quitFocusOnPart() { clearInterval(focusState.interval) focusState.interval = undefined } + + if (!focusState.isScrolling) { + viewPortScrollingState.isProgrammaticScrollInProgress = false + viewPortScrollingState.lastProgrammaticScrollTime = Date.now() + } +} + +export function resetViewportScrollState(): void { + quitFocusOnPart() + clearPendingScrollState() + viewPortScrollingState.isProgrammaticScrollInProgress = false +} + +export function clearViewportLifecycleState(): void { + resetViewportScrollState() + viewPortScrollingState.lastProgrammaticScrollTime = 0 + focusState.isScrolling = false + focusState.startTime = 0 } export async function scrollToPartInstance( @@ -156,6 +192,8 @@ export async function scrollToSegment( forceScroll?: boolean, noAnimation?: boolean ): Promise { + clearPendingScrollState() + const elementToScrollTo: HTMLElement | null = getElementToScrollTo(elementToScrollToOrSegmentId, false) const historyTarget: HTMLElement | null = getElementToScrollTo(elementToScrollToOrSegmentId, true) @@ -268,6 +306,7 @@ async function innerScrollToSegment( }, (error) => { if (!error.toString().match(/another scroll/)) logger.error(error) + currentScrollingElement = undefined return false } ) @@ -315,7 +354,9 @@ export async function scrollToPosition(scrollPosition: number, noAnimation?: boo behavior: 'smooth', }) await new Promise((resolve) => setTimeout(resolve, 300)) + viewPortScrollingState.isProgrammaticScrollInProgress = false + viewPortScrollingState.lastProgrammaticScrollTime = Date.now() } } diff --git a/packages/webui/src/client/ui/ClockView/CameraScreen/index.tsx b/packages/webui/src/client/ui/ClockView/CameraScreen/index.tsx index 0973ea37588..775dab8096e 100644 --- a/packages/webui/src/client/ui/ClockView/CameraScreen/index.tsx +++ b/packages/webui/src/client/ui/ClockView/CameraScreen/index.tsx @@ -25,7 +25,11 @@ import { useBlackBrowserTheme } from '../../../lib/useBlackBrowserTheme.js' import { useWakeLock } from './useWakeLock.js' import { useDebounce } from '../../../lib/lib.js' import { CorelibPubSub } from '@sofie-automation/corelib/dist/pubsub' -import { useSetDocumentClass, useSetDocumentDarkTheme } from '../../util/useSetDocumentClass.js' +import { + useSetDocumentClass, + useSetDocumentDarkTheme, + useOwnedElementClassToggle, +} from '../../util/useSetDocumentClass.js' import type { UIStudio } from '@sofie-automation/corelib/src/dataModel/Studio.js' import type { PartInstance } from '@sofie-automation/corelib/src/dataModel/PartInstance.js' import type { PieceExtended } from '@sofie-automation/corelib/src/dataModel/Piece.js' @@ -145,15 +149,7 @@ export function CameraScreen({ playlist, studioId }: Readonly): JSX.Elem useSetDocumentClass('dark', 'xdark', 'vertical-overflow-only') useSetDocumentDarkTheme() - - useEffect(() => { - const containerEl = document.querySelector('#render-target > .container-fluid.header-clear') - if (containerEl) containerEl.classList.remove('header-clear') - - return () => { - if (containerEl) containerEl.classList.add('header-clear') - } - }, []) + useOwnedElementClassToggle('#render-target > .container-fluid', 'header-clear') const studio = useTracker(() => UIStudios.findOne(studioId), [studioId], undefined) diff --git a/packages/webui/src/client/ui/PreviewPopUp/PreviewPopUp.tsx b/packages/webui/src/client/ui/PreviewPopUp/PreviewPopUp.tsx index 8a7fa39a809..39562d93953 100644 --- a/packages/webui/src/client/ui/PreviewPopUp/PreviewPopUp.tsx +++ b/packages/webui/src/client/ui/PreviewPopUp/PreviewPopUp.tsx @@ -5,6 +5,10 @@ import type { Padding, Placement, VirtualElement } from '@popperjs/core' import './PreviewPopUp.scss' +function isDetachedHTMLElementAnchor(anchor: HTMLElement | VirtualElement | null): anchor is HTMLElement { + return anchor instanceof HTMLElement && !anchor.isConnected +} + export const PreviewPopUp = React.forwardRef< PreviewPopUpHandle, React.PropsWithChildren<{ @@ -62,6 +66,8 @@ export const PreviewPopUp = React.forwardRef< anchor?.getBoundingClientRect().y ?? 0 ), }) + const anchorRef = useRef(anchor) + const anchorYRef = useRef(anchor?.getBoundingClientRect().y ?? 0) const { styles, attributes, update } = usePopper( trackMouse ? virtualElement.current : anchor, popperEl, @@ -74,13 +80,16 @@ export const PreviewPopUp = React.forwardRef< updateRef.current = update }, [update]) + useEffect(() => { + anchorRef.current = anchor + anchorYRef.current = anchor?.getBoundingClientRect().y ?? 0 + }, [anchor]) + useEffect(() => { if (trackMouse) { const listener = ({ clientX: x }: MouseEvent) => { - virtualElement.current.getBoundingClientRect = generateGetBoundingClientRect( - x, - anchor?.getBoundingClientRect().y ?? 0 - ) + if (isDetachedHTMLElementAnchor(anchorRef.current)) return + virtualElement.current.getBoundingClientRect = generateGetBoundingClientRect(x, anchorYRef.current) // If update is available, call it to reposition the popper: if (updateRef.current) { updateRef.current().catch((e) => console.error(e)) @@ -92,11 +101,21 @@ export const PreviewPopUp = React.forwardRef< document.removeEventListener('mousemove', listener) } } - }, [trackMouse, anchor]) + }, [trackMouse]) + + useEffect(() => { + return () => { + anchorRef.current = null + anchorYRef.current = 0 + virtualElement.current.getBoundingClientRect = generateGetBoundingClientRect(0, 0) + updateRef.current = null + } + }, []) useImperativeHandle(ref, () => { return { update: () => { + if (isDetachedHTMLElementAnchor(anchorRef.current)) return if (!updateRef.current) return updateRef.current().catch(console.error) }, diff --git a/packages/webui/src/client/ui/PreviewPopUp/PreviewPopUpContext.tsx b/packages/webui/src/client/ui/PreviewPopUp/PreviewPopUpContext.tsx index c6fc27f329d..57fcb58bd00 100644 --- a/packages/webui/src/client/ui/PreviewPopUp/PreviewPopUpContext.tsx +++ b/packages/webui/src/client/ui/PreviewPopUp/PreviewPopUpContext.tsx @@ -1,4 +1,4 @@ -import React, { useRef, useState } from 'react' +import React, { useCallback, useEffect, useRef, useState } from 'react' import { PreviewPopUp, type PreviewPopUpHandle } from './PreviewPopUp.js' import type { Padding, Placement } from '@popperjs/core' import { PreviewPopUpContent } from './PreviewPopUpContent.js' @@ -389,14 +389,78 @@ interface PreviewSession { export function PreviewPopUpContextProvider({ children }: React.PropsWithChildren<{}>): React.ReactNode { const currentHandle = useRef() const previewRef = useRef(null) + const closeSessionRef = useRef<() => void>(() => undefined) const [previewSession, setPreviewSession] = useState(null) const [previewContent, setPreviewContent] = useState(null) const [t, setTime] = useState(null) + const [previewSessionKey, setPreviewSessionKey] = useState(0) + + const isDetachedHTMLElement = (anchor: HTMLElement | VirtualElement): boolean => { + return anchor instanceof HTMLElement && !anchor.isConnected + } + + const closeSession = useCallback(() => { + const previousHandle = currentHandle.current + if (previousHandle) { + currentHandle.current = undefined + previousHandle.onClosed?.() + } + + setPreviewSession(null) + setPreviewContent(null) + setTime(null) + }, []) + + useEffect(() => { + closeSessionRef.current = closeSession + }, [closeSession]) + + useEffect(() => { + return () => { + closeSession() + } + }, [closeSession]) + + useEffect(() => { + if (!previewSession) return + const anchor = previewSession.anchor + if (!(anchor instanceof HTMLElement)) return + + let rafHandle: number | undefined + const checkAnchorConnection = () => { + if (!anchor.isConnected) { + closeSessionRef.current() + return + } + rafHandle = window.requestAnimationFrame(checkAnchorConnection) + } + + rafHandle = window.requestAnimationFrame(checkAnchorConnection) + + return () => { + if (rafHandle !== undefined) { + window.cancelAnimationFrame(rafHandle) + } + } + }, [previewSession]) const context: IPreviewPopUpContext = { requestPreview: (anchor, content, opts) => { - if (opts?.time) { + if (isDetachedHTMLElement(anchor)) { + closeSession() + const closedHandle: IPreviewPopUpSession = { + close: () => undefined, + update: () => undefined, + setPointerTime: () => undefined, + } + return closedHandle + } + + closeSession() + setPreviewSessionKey((prev) => prev + 1) + + if (opts?.time !== undefined) { setTime(opts.time) } else { setTime(null) @@ -413,15 +477,26 @@ export function PreviewPopUpContextProvider({ children }: React.PropsWithChildre const handle: IPreviewPopUpSession = { close: () => { - setPreviewSession(null) + if (currentHandle.current !== handle) return + closeSession() }, update: (contents) => { + if (currentHandle.current !== handle) return + if (isDetachedHTMLElement(anchor)) { + closeSession() + return + } if (contents) { setPreviewContent(contents) } previewRef.current?.update() }, setPointerTime: (t) => { + if (currentHandle.current !== handle) return + if (isDetachedHTMLElement(anchor)) { + closeSession() + return + } setTime(t) }, } @@ -436,6 +511,7 @@ export function PreviewPopUpContextProvider({ children }: React.PropsWithChildre {children} {previewSession && ( , IState> { private _hideNotificationsAfterMount: number | undefined + private _goToTopIdleCallback: number | undefined + private _goToLiveSegmentShortTimeout: ReturnType | undefined + private _goToLiveSegmentLongTimeout: ReturnType | undefined + private _headerNoteHighlightTimeout: ReturnType | undefined constructor(props: Translated) { super(props) @@ -609,6 +614,8 @@ const RundownViewContent = translateWithTracker { + if (this._goToTopIdleCallback !== undefined) { + window.cancelIdleCallback(this._goToTopIdleCallback) + this._goToTopIdleCallback = undefined + } + if (this._goToLiveSegmentShortTimeout) { + clearTimeout(this._goToLiveSegmentShortTimeout) + this._goToLiveSegmentShortTimeout = undefined + } + if (this._goToLiveSegmentLongTimeout) { + clearTimeout(this._goToLiveSegmentLongTimeout) + this._goToLiveSegmentLongTimeout = undefined + } + if (this._headerNoteHighlightTimeout) { + clearTimeout(this._headerNoteHighlightTimeout) + this._headerNoteHighlightTimeout = undefined + } + } + private onGoToTop = () => { scrollToPosition(0).catch((error) => { if (!error.toString().match(/another scroll/)) console.warn(error) }) - window.requestIdleCallback( + if (this._goToTopIdleCallback !== undefined) { + window.cancelIdleCallback(this._goToTopIdleCallback) + } + + this._goToTopIdleCallback = window.requestIdleCallback( () => { + this._goToTopIdleCallback = undefined this.setState({ followLiveSegments: true, }) @@ -701,6 +732,15 @@ const RundownViewContent = translateWithTracker { + if (this._goToLiveSegmentShortTimeout) { + clearTimeout(this._goToLiveSegmentShortTimeout) + this._goToLiveSegmentShortTimeout = undefined + } + if (this._goToLiveSegmentLongTimeout) { + clearTimeout(this._goToLiveSegmentLongTimeout) + this._goToLiveSegmentLongTimeout = undefined + } + if ( this.props.playlist && this.props.playlist.activationId && @@ -711,14 +751,16 @@ const RundownViewContent = translateWithTracker { + this._goToLiveSegmentShortTimeout = setTimeout(() => { + this._goToLiveSegmentShortTimeout = undefined if (this.props.playlist && this.props.playlist.nextPartInfo) { scrollToPartInstance(this.props.playlist.nextPartInfo.partInstanceId, true).catch((error) => { if (!error.toString().match(/another scroll/)) console.warn(error) }) } }, 120) - setTimeout(() => { + this._goToLiveSegmentLongTimeout = setTimeout(() => { + this._goToLiveSegmentLongTimeout = undefined this.setState({ followLiveSegments: true, }) @@ -731,7 +773,8 @@ const RundownViewContent = translateWithTracker { if (!error.toString().match(/another scroll/)) console.warn(error) }) - setTimeout(() => { + this._goToLiveSegmentLongTimeout = setTimeout(() => { + this._goToLiveSegmentLongTimeout = undefined this.setState({ followLiveSegments: true, }) @@ -897,7 +940,11 @@ const RundownViewContent = translateWithTracker { - let scrollDelta = 0 - if ( - (!e.ctrlKey && e.altKey && !e.metaKey && !e.shiftKey) || - (e.ctrlKey && !e.metaKey && !e.shiftKey && e.altKey) - ) { - // this.props.onScroll(Math.max(0, this.props.scrollLeft + e.deltaY / this.props.timeScale), e) - scrollDelta = e.deltaY * -1 - e.preventDefault() - } else if (!e.ctrlKey && !e.altKey && !e.metaKey && !e.shiftKey) { - // no modifier - if (e.deltaX !== 0) { - // this.props.onScroll(Math.max(0, this.props.scrollLeft + e.deltaX / this.props.timeScale), e) - scrollDelta = e.deltaX * -1 + const onSegmentWheel = useCallback( + (e: WheelEvent) => { + let scrollDelta = 0 + if ( + (!e.ctrlKey && e.altKey && !e.metaKey && !e.shiftKey) || + (e.ctrlKey && !e.metaKey && !e.shiftKey && e.altKey) + ) { + // this.props.onScroll(Math.max(0, this.props.scrollLeft + e.deltaY / this.props.timeScale), e) + scrollDelta = e.deltaY * -1 e.preventDefault() + } else if (!e.ctrlKey && !e.altKey && !e.metaKey && !e.shiftKey) { + // no modifier + if (e.deltaX !== 0) { + // this.props.onScroll(Math.max(0, this.props.scrollLeft + e.deltaX / this.props.timeScale), e) + scrollDelta = e.deltaX * -1 + e.preventDefault() + } } - } - if (scrollDelta !== 0) { - setScrollLeft((value) => { - const newScrollLeft = Math.max(0, Math.min(value - scrollDelta, maxScrollLeft)) - props.onScroll(newScrollLeft, e) - return newScrollLeft - }) - } - } + if (scrollDelta !== 0) { + setScrollLeft((value) => { + const newScrollLeft = Math.max(0, Math.min(value - scrollDelta, maxScrollLeft)) + props.onScroll(newScrollLeft, e) + return newScrollLeft + }) + } + }, + [maxScrollLeft, props.onScroll] + ) useEffect(() => { if (!grabbed) return @@ -420,7 +423,7 @@ export const SegmentAdlibTesting = React.memo( return () => { segment.removeEventListener('wheel', onSegmentWheel) } - }, [innerRef.current]) + }, [onSegmentWheel]) return (
0) - previewSession.current = previewContext.requestPreview(e.target as any, previewContents, { + previewSession.current = previewContext.requestPreview(e.currentTarget, previewContents, { ...previewOptions, time: mousePosition * (piece.instance.piece.content.sourceDuration || 0), initialOffsetX: e.screenX, @@ -162,6 +167,15 @@ export function LinePartMainPiece({ } } + useEffect(() => { + return () => { + if (previewSession.current) { + previewSession.current.close() + previewSession.current = null + } + } + }, []) + const onPointerLeave = (e: React.PointerEvent) => { if (e.pointerType !== 'mouse') { return diff --git a/packages/webui/src/client/ui/SegmentList/LinePartPieceIndicator/LinePartAdLibIndicator.tsx b/packages/webui/src/client/ui/SegmentList/LinePartPieceIndicator/LinePartAdLibIndicator.tsx index 414556b2941..1c402f87da3 100644 --- a/packages/webui/src/client/ui/SegmentList/LinePartPieceIndicator/LinePartAdLibIndicator.tsx +++ b/packages/webui/src/client/ui/SegmentList/LinePartPieceIndicator/LinePartAdLibIndicator.tsx @@ -1,4 +1,4 @@ -import { useCallback, useMemo } from 'react' +import { useCallback, useEffect, useMemo, useRef } from 'react' import type { PartId } from '@sofie-automation/corelib/dist/dataModel/Ids' import type { AdLibAction } from '@sofie-automation/corelib/dist/dataModel/AdlibAction' import type { AdLibPiece } from '@sofie-automation/corelib/dist/dataModel/AdLibPiece' @@ -20,6 +20,7 @@ interface IProps { export const LinePartAdLibIndicator: React.FC = function LinePartAdLibIndicator({ sourceLayers, partId }) { const { t } = useTranslation() + const revealTimeoutRef = useRef | undefined>(undefined) const sourceLayerIds = useMemo(() => sourceLayers.map((sourceLayer) => sourceLayer._id), [sourceLayers]) const label = useMemo(() => sourceLayers[0]?.name ?? '', [sourceLayers]) @@ -64,13 +65,22 @@ export const LinePartAdLibIndicator: React.FC = function LinePartAdLibIn RundownViewEventBus.emit(RundownViewEvents.SHELF_STATE, { state: true, }) - setTimeout(() => { + revealTimeoutRef.current = setTimeout(() => { RundownViewEventBus.emit(RundownViewEvents.REVEAL_IN_SHELF, { pieceId: pieceId, }) }, 100) }, [adLibPieces, adLibActions]) + useEffect(() => { + return () => { + if (revealTimeoutRef.current) { + clearTimeout(revealTimeoutRef.current) + revealTimeoutRef.current = undefined + } + } + }, []) + return ( {(studio) => { diff --git a/packages/webui/src/client/ui/SegmentList/LinePartPieceIndicator/LinePartIndicator.tsx b/packages/webui/src/client/ui/SegmentList/LinePartPieceIndicator/LinePartIndicator.tsx index 68c7de15d8f..28d45575fd9 100644 --- a/packages/webui/src/client/ui/SegmentList/LinePartPieceIndicator/LinePartIndicator.tsx +++ b/packages/webui/src/client/ui/SegmentList/LinePartPieceIndicator/LinePartIndicator.tsx @@ -49,7 +49,6 @@ export function LinePartIndicator({ ) return setIsMenuOpen(false) - window.removeEventListener('mousedown', onClickAway) }, [element] ) @@ -58,14 +57,16 @@ export function LinePartIndicator({ const shouldBeOpen = !isMenuOpen setIsMenuOpen(shouldBeOpen) onClickExternal?.(e) - window.addEventListener('mousedown', onClickAway) } useEffect(() => { + if (!isMenuOpen) return + window.addEventListener('mousedown', onClickAway) + return () => { window.removeEventListener('mousedown', onClickAway) } - }, []) + }, [isMenuOpen, onClickAway]) return ( <> diff --git a/packages/webui/src/client/ui/SegmentList/LinePartPieceIndicator/LinePartScriptPiece.tsx b/packages/webui/src/client/ui/SegmentList/LinePartPieceIndicator/LinePartScriptPiece.tsx index 482bc6b136f..97c2633e0ab 100644 --- a/packages/webui/src/client/ui/SegmentList/LinePartPieceIndicator/LinePartScriptPiece.tsx +++ b/packages/webui/src/client/ui/SegmentList/LinePartPieceIndicator/LinePartScriptPiece.tsx @@ -1,5 +1,5 @@ import { SourceLayerType } from '@sofie-automation/blueprints-integration' -import { useContext, useMemo, useRef } from 'react' +import { useContext, useEffect, useMemo, useRef } from 'react' import { PreviewPopUpContext, type IPreviewPopUpSession, @@ -37,13 +37,27 @@ export function LinePartScriptPiece({ pieces }: IProps): JSX.Element { function onMouseEnter(e: React.PointerEvent) { // setHover(true) + if (previewSession.current) { + previewSession.current.close() + previewSession.current = null + } + if (previewProps?.contents && previewProps.contents.length > 0) - previewSession.current = previewContext.requestPreview(e.target as any, previewProps.contents, { + previewSession.current = previewContext.requestPreview(e.currentTarget, previewProps.contents, { ...previewProps.options, initialOffsetX: e.screenX, }) } + useEffect(() => { + return () => { + if (previewSession.current) { + previewSession.current.close() + previewSession.current = null + } + } + }, []) + function onMouseLeave() { if (previewSession.current) { previewSession.current.close() diff --git a/packages/webui/src/client/ui/SegmentList/LinePartPieceIndicator/PieceIndicatorMenu.tsx b/packages/webui/src/client/ui/SegmentList/LinePartPieceIndicator/PieceIndicatorMenu.tsx index 647064170b3..59bed34e919 100644 --- a/packages/webui/src/client/ui/SegmentList/LinePartPieceIndicator/PieceIndicatorMenu.tsx +++ b/packages/webui/src/client/ui/SegmentList/LinePartPieceIndicator/PieceIndicatorMenu.tsx @@ -31,7 +31,7 @@ export function PieceIndicatorMenu({ }, [pieces.length]) useEffect(() => { - if (!indicatorMenuEl) return + if (!indicatorMenuEl || !indicatorMenuEl.isConnected) return let timeout: NodeJS.Timeout | undefined = undefined @@ -55,7 +55,7 @@ export function PieceIndicatorMenu({ indicatorMenuEl.removeEventListener('mouseenter', onMouseEnter) indicatorMenuEl.removeEventListener('mouseleave', onMouseLeave) } - }, [indicatorMenuEl]) + }, [indicatorMenuEl, setIsOver]) useLayoutEffect(() => { if (!indicatorMenuEl) return @@ -78,7 +78,7 @@ export function PieceIndicatorMenu({
el !== null && setIndicatorMenuEl(el)} + ref={setIndicatorMenuEl} style={styles.popper} {...attributes.popper} > diff --git a/packages/webui/src/client/ui/SegmentList/LinePartSecondaryPiece/LinePartSecondaryPiece.tsx b/packages/webui/src/client/ui/SegmentList/LinePartSecondaryPiece/LinePartSecondaryPiece.tsx index db249adb4d6..617622d7374 100644 --- a/packages/webui/src/client/ui/SegmentList/LinePartSecondaryPiece/LinePartSecondaryPiece.tsx +++ b/packages/webui/src/client/ui/SegmentList/LinePartSecondaryPiece/LinePartSecondaryPiece.tsx @@ -1,5 +1,5 @@ import classNames from 'classnames' -import React, { type CSSProperties, useCallback, useContext, useMemo, useRef } from 'react' +import React, { type CSSProperties, useCallback, useContext, useEffect, useMemo, useRef } from 'react' import { useContentStatusForPieceInstance } from '../../SegmentTimeline/withMediaObjectStatus.js' import { PreviewPopUpContext, @@ -57,13 +57,27 @@ export const LinePartSecondaryPiece: React.FC = React.memo(function Line return } + if (previewSession.current) { + previewSession.current.close() + previewSession.current = null + } + if (previewProps.contents.length > 0) - previewSession.current = previewContext.requestPreview(e.target as any, previewProps.contents, { + previewSession.current = previewContext.requestPreview(e.currentTarget, previewProps.contents, { ...previewProps.options, initialOffsetX: e.screenX, }) } + useEffect(() => { + return () => { + if (previewSession.current) { + previewSession.current.close() + previewSession.current = null + } + } + }, []) + const onPointerLeave = (e: React.PointerEvent) => { if (e.pointerType !== 'mouse') { return diff --git a/packages/webui/src/client/ui/SegmentList/SegmentList.tsx b/packages/webui/src/client/ui/SegmentList/SegmentList.tsx index 953552cb4fc..82bc22a68d1 100644 --- a/packages/webui/src/client/ui/SegmentList/SegmentList.tsx +++ b/packages/webui/src/client/ui/SegmentList/SegmentList.tsx @@ -195,15 +195,22 @@ const SegmentListInner = React.forwardRef(function Segme return } - const partEl = combinedRef.current.querySelector('.segment-opl__part') + const segmentEl = combinedRef.current + const partEl = segmentEl.querySelector('.segment-opl__part') if (!partEl) return - const { top, height } = combinedRef.current.getBoundingClientRect() + const { top, height } = segmentEl.getBoundingClientRect() const absoluteTop = top + window.scrollY const { height: partHeight } = partEl.getBoundingClientRect() + const threshold = absoluteTop + height - getHeaderHeight() - partHeight * 2 - 10 function onScroll() { - if (window.scrollY > absoluteTop + height - getHeaderHeight() - partHeight * 2 - 10) { + if (!segmentEl.isConnected) { + setHeaderDetachedStick(false) + return + } + + if (window.scrollY > threshold) { setHeaderDetachedStick(true) } else { setHeaderDetachedStick(false) diff --git a/packages/webui/src/client/ui/SegmentStoryboard/SegmentStoryboard.tsx b/packages/webui/src/client/ui/SegmentStoryboard/SegmentStoryboard.tsx index 99815b1ac2b..3f6c1334f0c 100644 --- a/packages/webui/src/client/ui/SegmentStoryboard/SegmentStoryboard.tsx +++ b/packages/webui/src/client/ui/SegmentStoryboard/SegmentStoryboard.tsx @@ -410,32 +410,35 @@ export const SegmentStoryboard = React.memo( } } - const onSegmentWheel = (e: WheelEvent) => { - let scrollDelta = 0 - if ( - (!e.ctrlKey && e.altKey && !e.metaKey && !e.shiftKey) || - (e.ctrlKey && !e.metaKey && !e.shiftKey && e.altKey) - ) { - // this.props.onScroll(Math.max(0, this.props.scrollLeft + e.deltaY / this.props.timeScale), e) - scrollDelta = e.deltaY * -1 - e.preventDefault() - } else if (!e.ctrlKey && !e.altKey && !e.metaKey && !e.shiftKey) { - // no modifier - if (e.deltaX !== 0) { - // this.props.onScroll(Math.max(0, this.props.scrollLeft + e.deltaX / this.props.timeScale), e) - scrollDelta = e.deltaX * -1 + const onSegmentWheel = useCallback( + (e: WheelEvent) => { + let scrollDelta = 0 + if ( + (!e.ctrlKey && e.altKey && !e.metaKey && !e.shiftKey) || + (e.ctrlKey && !e.metaKey && !e.shiftKey && e.altKey) + ) { + // this.props.onScroll(Math.max(0, this.props.scrollLeft + e.deltaY / this.props.timeScale), e) + scrollDelta = e.deltaY * -1 e.preventDefault() + } else if (!e.ctrlKey && !e.altKey && !e.metaKey && !e.shiftKey) { + // no modifier + if (e.deltaX !== 0) { + // this.props.onScroll(Math.max(0, this.props.scrollLeft + e.deltaX / this.props.timeScale), e) + scrollDelta = e.deltaX * -1 + e.preventDefault() + } } - } - if (scrollDelta !== 0) { - setScrollLeft((value) => { - const newScrollLeft = Math.max(0, Math.min(value - scrollDelta, maxScrollLeft)) - props.onScroll(newScrollLeft, e) - return newScrollLeft - }) - } - } + if (scrollDelta !== 0) { + setScrollLeft((value) => { + const newScrollLeft = Math.max(0, Math.min(value - scrollDelta, maxScrollLeft)) + props.onScroll(newScrollLeft, e) + return newScrollLeft + }) + } + }, + [maxScrollLeft, props.onScroll] + ) useEffect(() => { if (!grabbed) return @@ -522,7 +525,7 @@ export const SegmentStoryboard = React.memo( return () => { segment.removeEventListener('wheel', onSegmentWheel) } - }, [innerRef.current]) + }, [onSegmentWheel]) const onScrollbarChange = useCallback((left: number) => { setScrollLeft(Math.max(0, Math.min(left, maxScrollLeft))) diff --git a/packages/webui/src/client/ui/SegmentStoryboard/StoryboardPartSecondaryPieces/StoryboardSecondaryPiece.tsx b/packages/webui/src/client/ui/SegmentStoryboard/StoryboardPartSecondaryPieces/StoryboardSecondaryPiece.tsx index e7599c76261..406db450c0b 100644 --- a/packages/webui/src/client/ui/SegmentStoryboard/StoryboardPartSecondaryPieces/StoryboardSecondaryPiece.tsx +++ b/packages/webui/src/client/ui/SegmentStoryboard/StoryboardPartSecondaryPieces/StoryboardSecondaryPiece.tsx @@ -1,4 +1,4 @@ -import { useImperativeHandle, useContext, useRef, useState, type RefObject } from 'react' +import { useImperativeHandle, useContext, useEffect, useRef, useState, type RefObject } from 'react' import { type ISourceLayer, SourceLayerType } from '@sofie-automation/blueprints-integration' import { DefaultRenderer } from './Renderers/DefaultRenderer.js' import { assertNever } from '@sofie-automation/corelib/dist/lib' @@ -120,8 +120,13 @@ export function StoryboardSecondaryPiece(props: IProps): JSX.Element { width, }) + if (previewSession.current) { + previewSession.current.close() + previewSession.current = null + } + if (previewContents.length > 0) - previewSession.current = previewContext.requestPreview(e.target as any, previewContents, { + previewSession.current = previewContext.requestPreview(e.currentTarget, previewContents, { ...previewOptions, initialOffsetX: e.screenX, }) @@ -129,6 +134,15 @@ export function StoryboardSecondaryPiece(props: IProps): JSX.Element { if (onPointerEnterCallback) onPointerEnterCallback(e) } + useEffect(() => { + return () => { + if (previewSession.current) { + previewSession.current.close() + previewSession.current = null + } + } + }, []) + const onPointerLeave = (e: React.PointerEvent) => { setHovering(null) diff --git a/packages/webui/src/client/ui/SegmentStoryboard/StoryboardPartThumbnail/StoryboardPartThumbnailInner.tsx b/packages/webui/src/client/ui/SegmentStoryboard/StoryboardPartThumbnail/StoryboardPartThumbnailInner.tsx index 105e40e9001..cc430429438 100644 --- a/packages/webui/src/client/ui/SegmentStoryboard/StoryboardPartThumbnail/StoryboardPartThumbnailInner.tsx +++ b/packages/webui/src/client/ui/SegmentStoryboard/StoryboardPartThumbnail/StoryboardPartThumbnailInner.tsx @@ -1,4 +1,4 @@ -import { useContext, useRef, useState } from 'react' +import { useContext, useEffect, useRef, useState } from 'react' import type { ISourceLayer } from '@sofie-automation/blueprints-integration' import { getElementDocumentOffset, type OffsetPosition } from '../../../utils/positions.js' import { getElementHeight, getElementWidth } from '../../../utils/dimensions.js' @@ -62,6 +62,11 @@ export function StoryboardPartThumbnailInner({ } setHover(true) + if (previewSession.current) { + previewSession.current.close() + previewSession.current = null + } + const newOffset = thumbnailEl.current && getElementDocumentOffset(thumbnailEl.current) if (newOffset !== null) { setOrigin(newOffset) @@ -76,13 +81,22 @@ export function StoryboardPartThumbnailInner({ } if (previewContents.length > 0) - previewSession.current = previewContext.requestPreview(e.target as any, previewContents, { + previewSession.current = previewContext.requestPreview(e.currentTarget, previewContents, { ...previewOptions, time: mousePosition * (piece.instance.piece.content.sourceDuration || 0), initialOffsetX: e.screenX, }) } + useEffect(() => { + return () => { + if (previewSession.current) { + previewSession.current.close() + previewSession.current = null + } + } + }, []) + const onPointerLeave = (e: React.PointerEvent) => { if (e.pointerType !== 'mouse') { return diff --git a/packages/webui/src/client/ui/SegmentTimeline/Parts/InvalidPartCover.tsx b/packages/webui/src/client/ui/SegmentTimeline/Parts/InvalidPartCover.tsx index 91da3e40c18..0e9388850a3 100644 --- a/packages/webui/src/client/ui/SegmentTimeline/Parts/InvalidPartCover.tsx +++ b/packages/webui/src/client/ui/SegmentTimeline/Parts/InvalidPartCover.tsx @@ -1,4 +1,4 @@ -import React, { useContext, useRef } from 'react' +import React, { useContext, useEffect, useRef } from 'react' import type { PartInvalidReason } from '@sofie-automation/corelib/dist/dataModel/Part' import { type IPreviewPopUpSession, PreviewPopUpContext } from '../../PreviewPopUp/PreviewPopUpContext.js' @@ -22,8 +22,13 @@ export function InvalidPartCover({ className, invalidReason }: Readonly) return } + if (previewSession.current) { + previewSession.current.close() + previewSession.current = null + } + if (invalidReason?.message && !previewSession.current) { - previewSession.current = previewContext.requestPreview(e.target as HTMLDivElement, [ + previewSession.current = previewContext.requestPreview(e.currentTarget, [ { type: 'warning', content: invalidReason.message, @@ -39,6 +44,15 @@ export function InvalidPartCover({ className, invalidReason }: Readonly) } } + useEffect(() => { + return () => { + if (previewSession.current) { + previewSession.current.close() + previewSession.current = null + } + } + }, []) + return (
{/* TODOD - add back hover with warnings */} diff --git a/packages/webui/src/client/ui/SegmentTimeline/Parts/SegmentTimelinePart.tsx b/packages/webui/src/client/ui/SegmentTimeline/Parts/SegmentTimelinePart.tsx index 2cfacb7409f..a726cd6714e 100644 --- a/packages/webui/src/client/ui/SegmentTimeline/Parts/SegmentTimelinePart.tsx +++ b/packages/webui/src/client/ui/SegmentTimeline/Parts/SegmentTimelinePart.tsx @@ -69,7 +69,6 @@ interface IProps { studio: UIStudio part: PartUi timeToPixelRatio: number - onCollapseOutputToggle?: (layer: IOutputLayerUi, event: any) => void collapsedOutputs: { [key: string]: boolean } diff --git a/packages/webui/src/client/ui/SegmentTimeline/Renderers/MicSourceRenderer.tsx b/packages/webui/src/client/ui/SegmentTimeline/Renderers/MicSourceRenderer.tsx index 4492f687a69..2d73d024aeb 100644 --- a/packages/webui/src/client/ui/SegmentTimeline/Renderers/MicSourceRenderer.tsx +++ b/packages/webui/src/client/ui/SegmentTimeline/Renderers/MicSourceRenderer.tsx @@ -30,6 +30,37 @@ export const MicSourceRenderer: React.ComponentType = withTranslation()( super(props) } + private mountLineItem(target: HTMLElement | null): void { + if (!this.lineItem || !target) return + if (!document.contains(target)) { + this.removeLineItem() + return + } + + if (this.lineItem.parentElement !== target) { + try { + this.lineItem.remove() + } catch (err) { + logger.error(err) + } + try { + target.appendChild(this.lineItem) + } catch (err) { + logger.error(err) + } + } + } + + private removeLineItem(): void { + if (!this.lineItem) return + + try { + this.lineItem.remove() + } catch (err) { + logger.error(err) + } + } + repositionLine = () => { if (!this.lineItem) return this.lineItem.style.left = this.linePosition + 'px' @@ -103,7 +134,7 @@ export const MicSourceRenderer: React.ComponentType = withTranslation()( this.updateAnchoredElsWidths() if (this.props.itemElement) { this.itemElement = this.props.itemElement - this.itemElement.parentElement?.parentElement?.parentElement?.appendChild(this.lineItem) + this.mountLineItem(this.itemElement.parentElement?.parentElement?.parentElement ?? null) this.refreshLine() } } @@ -148,15 +179,11 @@ export const MicSourceRenderer: React.ComponentType = withTranslation()( // Move the line element if (this.itemElement !== this.props.itemElement) { if (this.itemElement && this.lineItem) { - try { - this.lineItem.remove() - } catch (err) { - logger.error(err) - } + this.removeLineItem() } this.itemElement = this.props.itemElement if (this.itemElement && this.lineItem) { - this.itemElement.parentElement?.parentElement?.parentElement?.appendChild(this.lineItem) + this.mountLineItem(this.itemElement.parentElement?.parentElement?.parentElement ?? null) _forceSizingRecheck = true } } @@ -176,12 +203,9 @@ export const MicSourceRenderer: React.ComponentType = withTranslation()( } componentWillUnmount(): void { - try { - // Remove the line element - this.lineItem?.remove() - } catch (err) { - logger.error(err) - } + this.removeLineItem() + this.lineItem = null + this.itemElement = null } render(): JSX.Element { diff --git a/packages/webui/src/client/ui/SegmentTimeline/Renderers/VTSourceRenderer.tsx b/packages/webui/src/client/ui/SegmentTimeline/Renderers/VTSourceRenderer.tsx index 9127a088036..68bd58c94d9 100644 --- a/packages/webui/src/client/ui/SegmentTimeline/Renderers/VTSourceRenderer.tsx +++ b/packages/webui/src/client/ui/SegmentTimeline/Renderers/VTSourceRenderer.tsx @@ -69,6 +69,62 @@ class VTSourceRendererBase extends CustomLayerItemRenderer, itemElement: HTMLElement | null ): Partial { - if (this.rightLabelContainer && itemElement) { + if (this.rightLabelContainer) { const itemDuration = this.getItemDuration(true) + const targetElement = this.getRightLabelTarget(itemElement) if (prevProps === null || itemElement !== prevProps.itemElement) { - if (itemDuration === Number.POSITIVE_INFINITY) { - itemElement.parentElement?.parentElement?.parentElement?.appendChild(this.rightLabelContainer) - - newState.rightLabelIsAppendage = true + if (targetElement) { + this.mountAuxiliaryNode(this.rightLabelContainer, targetElement) + newState.rightLabelIsAppendage = itemDuration === Number.POSITIVE_INFINITY } else { - try { - this.rightLabelContainer?.remove() - } catch (err) { - logger.error(`Error in VTSourceRendererBase.mountRightLabelContainer 1: ${stringifyError(err)}`) - } - itemElement.appendChild(this.rightLabelContainer) + this.removeAuxiliaryNode(this.rightLabelContainer) newState.rightLabelIsAppendage = false } } else if (prevProps?.partDuration !== props.partDuration) { - if (itemDuration === Number.POSITIVE_INFINITY && this.state.rightLabelIsAppendage !== true) { - itemElement.parentElement?.parentElement?.parentElement?.appendChild(this.rightLabelContainer) - - newState.rightLabelIsAppendage = true - } else if (itemDuration !== Number.POSITIVE_INFINITY && this.state.rightLabelIsAppendage === true) { - try { - this.rightLabelContainer?.remove() - } catch (err) { - logger.error(`Error in VTSourceRendererBase.mountRightLabelContainer 2: ${stringifyError(err)}`) - } - itemElement.appendChild(this.rightLabelContainer) + if (targetElement) { + this.mountAuxiliaryNode(this.rightLabelContainer, targetElement) + newState.rightLabelIsAppendage = itemDuration === Number.POSITIVE_INFINITY + } else if (this.state.rightLabelIsAppendage !== false) { + this.removeAuxiliaryNode(this.rightLabelContainer) newState.rightLabelIsAppendage = false } } @@ -126,32 +171,25 @@ class VTSourceRendererBase extends CustomLayerItemRenderer { const { relative: relativeRendering, isLiveLine, outputLayer } = props + const targetElement = this.getCountdownTarget(itemElement) if ( this.countdownContainer && !this.state.sourceEndCountdownAppendage && !relativeRendering && isLiveLine && !outputLayer.collapsed && - itemElement + targetElement ) { - const liveLine = - itemElement.parentElement?.parentElement?.parentElement?.parentElement?.parentElement?.querySelector( - '.segment-timeline__liveline' - ) - if (liveLine) { - liveLine.appendChild(this.countdownContainer) + if (targetElement) { + this.mountAuxiliaryNode(this.countdownContainer, targetElement) newState.sourceEndCountdownAppendage = true } } else if ( this.countdownContainer && this.state.sourceEndCountdownAppendage && - !(!relativeRendering && isLiveLine && !outputLayer.collapsed && itemElement) + !(!relativeRendering && isLiveLine && !outputLayer.collapsed && targetElement) ) { - try { - this.countdownContainer.remove() - } catch (err) { - logger.error(`Error in VTSourceRendererBase.mountSourceEndedCountdownContainer 1: ${stringifyError(err)}`) - } + this.removeAuxiliaryNode(this.countdownContainer) newState.sourceEndCountdownAppendage = false } @@ -224,23 +262,10 @@ class VTSourceRendererBase extends CustomLayerItemRenderer )} {this.leftLabelNodes} - {this.rightLabelContainer && ReactDOM.createPortal(this.rightLabelNodes, this.rightLabelContainer)} + {this.rightLabelContainer && + document.contains(this.rightLabelContainer) && + ReactDOM.createPortal(this.rightLabelNodes, this.rightLabelContainer)} ) } diff --git a/packages/webui/src/client/ui/SegmentTimeline/SegmentTimeline.tsx b/packages/webui/src/client/ui/SegmentTimeline/SegmentTimeline.tsx index 8e59e83b990..fa1a56cb5bf 100644 --- a/packages/webui/src/client/ui/SegmentTimeline/SegmentTimeline.tsx +++ b/packages/webui/src/client/ui/SegmentTimeline/SegmentTimeline.tsx @@ -248,7 +248,8 @@ export class SegmentTimelineClass extends React.Component { + this.showEntireSegmentTimeout = setTimeout(() => { + this.showEntireSegmentTimeout = undefined // TODO: This doesn't actually handle having new parts added/removed, which should cause the segment to re-scale! if (this.props.onShowEntireSegment) { this.props.onShowEntireSegment(undefined) @@ -258,6 +259,10 @@ export class SegmentTimelineClass extends React.Component { if (e.segmentId === this.props.segment._id && !e.partId && !e.pieceId) { @@ -762,7 +772,6 @@ export class SegmentTimelineClass extends React.Component a._rank - b._rank) } + private isOutputLayerCollapsible(outputLayer: IOutputLayerUi): boolean { + return outputLayer.sourceLayers !== undefined && outputLayer.sourceLayers.length > 1 && !outputLayer.isFlattened + } + private renderOutputLayerControls(outputGroups: IOutputLayerUi[]) { const showHiddenSourceLayers = getShowHiddenSourceLayers() @@ -877,8 +889,7 @@ export class SegmentTimelineClass extends React.Component 1 && !outputLayer.isFlattened + const isCollapsible = this.isOutputLayerCollapsible(outputLayer) return (
{ - window.requestAnimationFrame(() => { + this.initialShowEntireSegmentTimeout = setTimeout(() => { + this.initialShowEntireSegmentTimeout = undefined + this.initialShowEntireSegmentRaf = window.requestAnimationFrame(() => { + this.initialShowEntireSegmentRaf = undefined this.mountedTime = Date.now() if (this.state.isLiveSegment && this.props.followLiveSegments && !this.isVisible) { scrollToSegment(this.props.segmentId, true).catch((error) => { @@ -349,14 +355,32 @@ const SegmentTimelineContainerContent = withResolvedSegment( } componentWillUnmount(): void { + this.isUnmounted = true + this.timelineDiv = null + this.rundownCurrentPartInstanceId = null if (this.intersectionObserver && this.state.isLiveSegment && this.props.followLiveSegments) { if (typeof this.props.onSegmentScroll === 'function') this.props.onSegmentScroll() } + if (this.initialShowEntireSegmentTimeout) { + clearTimeout(this.initialShowEntireSegmentTimeout) + this.initialShowEntireSegmentTimeout = undefined + } + if (this.initialShowEntireSegmentRaf !== undefined) { + window.cancelAnimationFrame(this.initialShowEntireSegmentRaf) + this.initialShowEntireSegmentRaf = undefined + } + + if (this.visibilityChangeTimeout) { + clearTimeout(this.visibilityChangeTimeout) + this.visibilityChangeTimeout = undefined + } + this.stopLive() RundownViewEventBus.off(RundownViewEvents.REWIND_SEGMENTS, this.onRewindSegment) RundownViewEventBus.off(RundownViewEvents.GO_TO_PART, this.onGoToPart) RundownViewEventBus.off(RundownViewEvents.GO_TO_PART_INSTANCE, this.onGoToPartInstance) + this.onWindowResize.cancel() window.removeEventListener('resize', this.onWindowResize) } @@ -373,6 +397,7 @@ const SegmentTimelineContainerContent = withResolvedSegment( }, 250) onTimeScaleChange = (timeScaleVal: number) => { + if (this.isUnmounted) return if (Number.isFinite(timeScaleVal) && timeScaleVal > 0) { this.setState((state) => ({ timeScale: timeScaleVal, @@ -382,6 +407,7 @@ const SegmentTimelineContainerContent = withResolvedSegment( } onCollapseOutputToggle = (outputLayer: IOutputLayerUi) => { + if (this.isUnmounted) return const collapsedOutputs = { ...this.state.collapsedOutputs } collapsedOutputs[outputLayer._id] = outputLayer.isDefaultCollapsed && collapsedOutputs[outputLayer._id] === undefined @@ -396,6 +422,7 @@ const SegmentTimelineContainerContent = withResolvedSegment( } /** The user has scrolled scrollLeft seconds to the left in a child component */ onScroll = (scrollLeft: number) => { + if (this.isUnmounted) return this.setState({ scrollLeft: Math.max( 0, @@ -411,6 +438,7 @@ const SegmentTimelineContainerContent = withResolvedSegment( } onRewindSegment = () => { + if (this.isUnmounted) return if (!this.state.isLiveSegment) { this.updateMaxTimeScale() .then(() => { @@ -425,6 +453,7 @@ const SegmentTimelineContainerContent = withResolvedSegment( } onGoToPartInner = (part: PartUi, zoomInToFit?: boolean) => { + if (this.isUnmounted) return this.setState((state) => { const timelineWidth = this.timelineDiv instanceof HTMLElement ? getElementWidth(this.timelineDiv) : 0 // unsure if this is good default/substitute let newScale: number | undefined @@ -492,6 +521,7 @@ const SegmentTimelineContainerContent = withResolvedSegment( } onAirLineRefresh = (e: TimingEvent) => { + if (this.isUnmounted) return this.setState((state) => { if (state.isLiveSegment && state.currentLivePart) { const currentLivePartInstance = state.currentLivePart.instance @@ -535,12 +565,14 @@ const SegmentTimelineContainerContent = withResolvedSegment( } visibleChanged = (entries: IntersectionObserverEntry[]) => { + if (this.isUnmounted) return // Add a small debounce to ensure UI has settled before checking if (this.visibilityChangeTimeout) { clearTimeout(this.visibilityChangeTimeout) } this.visibilityChangeTimeout = setTimeout(() => { + if (this.isUnmounted) return if (entries[0].intersectionRatio < 0.99 && !isMaintainingFocus() && Date.now() - this.mountedTime > 2000) { if (typeof this.props.onSegmentScroll === 'function') this.props.onSegmentScroll() this.isVisible = false diff --git a/packages/webui/src/client/ui/SegmentTimeline/SourceLayerItem.tsx b/packages/webui/src/client/ui/SegmentTimeline/SourceLayerItem.tsx index 1bf03b10918..1d51c667264 100644 --- a/packages/webui/src/client/ui/SegmentTimeline/SourceLayerItem.tsx +++ b/packages/webui/src/client/ui/SegmentTimeline/SourceLayerItem.tsx @@ -166,6 +166,15 @@ export const SourceLayerItem = (props: Readonly): JSX.Ele useEffect(() => { return () => { clearTimeout(highlightTimeout.current) + if (animFrameHandle.current !== undefined) { + cancelAnimationFrame(animFrameHandle.current) + animFrameHandle.current = undefined + } + if (previewSession.current) { + previewSession.current.close() + previewSession.current = null + } + itemElementRef.current = null } }, []) @@ -270,13 +279,23 @@ export const SourceLayerItem = (props: Readonly): JSX.Ele } animFrameHandle.current = requestAnimationFrame(updatePos) - }, [piece, contentStatus, timeScale]) + }, [timeScale]) const togglePreviewPopUp = useCallback( (e: React.MouseEvent, state: boolean) => { + if (animFrameHandle.current !== undefined) { + cancelAnimationFrame(animFrameHandle.current) + animFrameHandle.current = undefined + } + if (!state && previewSession.current) { previewSession.current.close() previewSession.current = null } else { + if (previewSession.current) { + previewSession.current.close() + previewSession.current = null + } + const { contents: previewContents, options: previewOptions } = convertSourceLayerItemToPreview( layer.type, piece.instance.piece, @@ -288,7 +307,7 @@ export const SourceLayerItem = (props: Readonly): JSX.Ele ) if (previewContents.length) { - previewSession.current = previewContext.requestPreview(e.target as any, previewContents, { + previewSession.current = previewContext.requestPreview(e.currentTarget, previewContents, { ...previewOptions, time: cursorTimePosition, initialOffsetX: e.screenX, @@ -308,9 +327,19 @@ export const SourceLayerItem = (props: Readonly): JSX.Ele animFrameHandle.current = requestAnimationFrame(updatePos) } else if (animFrameHandle.current !== undefined) { cancelAnimationFrame(animFrameHandle.current) + animFrameHandle.current = undefined } }, - [piece, cursorTimePosition, contentStatus, timeScale] + [ + piece, + cursorTimePosition, + contentStatus, + updatePos, + layer.type, + previewContext, + props.piece.renderedDuration, + props.piece.renderedInPoint, + ] ) const moveMiniInspector = useCallback((e: MouseEvent | any) => { cursorRawPosition.current = { diff --git a/packages/webui/src/client/ui/SegmentTimeline/TimelineGrid.tsx b/packages/webui/src/client/ui/SegmentTimeline/TimelineGrid.tsx index 444aaac987c..460184bbf9f 100644 --- a/packages/webui/src/client/ui/SegmentTimeline/TimelineGrid.tsx +++ b/packages/webui/src/client/ui/SegmentTimeline/TimelineGrid.tsx @@ -442,6 +442,10 @@ export class TimelineGrid extends React.Component { } componentWillUnmount(): void { + if (typeof this.scheduledRepaint === 'number') { + window.cancelAnimationFrame(this.scheduledRepaint) + this.scheduledRepaint = null + } if (this._resizeObserver) this._resizeObserver.disconnect() window.removeEventListener(RundownTiming.Events.timeupdateLowResolution, this.onTimeupdate) window.removeEventListener(RundownTiming.Events.timeupdateHighResolution, this.onTimeupdate) diff --git a/packages/webui/src/client/ui/Shelf/DashboardPieceButton/usePreviewPopUpSession.ts b/packages/webui/src/client/ui/Shelf/DashboardPieceButton/usePreviewPopUpSession.ts index 8e8b85eab1f..2c96f11f07f 100644 --- a/packages/webui/src/client/ui/Shelf/DashboardPieceButton/usePreviewPopUpSession.ts +++ b/packages/webui/src/client/ui/Shelf/DashboardPieceButton/usePreviewPopUpSession.ts @@ -18,7 +18,7 @@ export function usePreviewPopUpSession(args: { contentStatus: ReadonlyDeep | undefined enableHoverPreview?: boolean }): { - openPreview: (e: EventTarget, time: number) => void + openPreview: (anchor: HTMLElement, time: number) => void closePreview: () => void setPointerTime: (time: number) => void hasPreview: boolean @@ -57,11 +57,12 @@ export function usePreviewPopUpSession(args: { }, []) const openPreview = useCallback( - (e: EventTarget, time: number) => { + (anchor: HTMLElement, time: number) => { if (!enableHoverPreview) return if (!previewRequest.contents.length) return + if (!anchor.isConnected) return previewSessionRef.current?.close() - previewSessionRef.current = args.previewContext.requestPreview(e as any, previewRequest.contents, { + previewSessionRef.current = args.previewContext.requestPreview(anchor, previewRequest.contents, { ...previewRequest.options, time, }) diff --git a/packages/webui/src/client/ui/Shelf/Renderers/L3rdListItemRenderer.tsx b/packages/webui/src/client/ui/Shelf/Renderers/L3rdListItemRenderer.tsx index 9f46261c775..42e339440de 100644 --- a/packages/webui/src/client/ui/Shelf/Renderers/L3rdListItemRenderer.tsx +++ b/packages/webui/src/client/ui/Shelf/Renderers/L3rdListItemRenderer.tsx @@ -99,7 +99,11 @@ export const L3rdListItemRenderer: React.FunctionComponent= 0 && unprocessedPercentage <= 1 && !showMiniInspector) { setShowMiniInspector(true) - previewSession.current = previewContext.requestPreview(e.target as any, previewContents, previewOptions) + if (previewSession.current) { + previewSession.current.close() + previewSession.current = null + } + previewSession.current = previewContext.requestPreview(e.currentTarget, previewContents, previewOptions) } } } @@ -130,6 +138,15 @@ export const L3rdListItemRenderer: React.FunctionComponent { + return () => { + if (previewSession.current) { + previewSession.current.close() + previewSession.current = null + } + } + }, []) + const type = props.adLibListItem.isAction ? props.adLibListItem.isGlobal ? 'rundownBaselineAdLibAction' diff --git a/packages/webui/src/client/ui/Shelf/Renderers/VTListItemRenderer.tsx b/packages/webui/src/client/ui/Shelf/Renderers/VTListItemRenderer.tsx index 01f08c612a1..a9c8e6f8b75 100644 --- a/packages/webui/src/client/ui/Shelf/Renderers/VTListItemRenderer.tsx +++ b/packages/webui/src/client/ui/Shelf/Renderers/VTListItemRenderer.tsx @@ -72,7 +72,7 @@ export const VTListItemRenderer: React.FunctionComponent { + return () => { + if (previewSession.current) { + previewSession.current.close() + previewSession.current = null + } + } + }, []) + const handleOnMouseMove = (e: React.MouseEvent) => { if (itemIconPosition) { const left = e.pageX - itemIconPosition.left let unprocessedPercentage = left / itemIconPosition.width if ((unprocessedPercentage > 1 || unprocessedPercentage < 0) && showMiniInspector) { setShowMiniInspector(false) + if (previewSession.current) { + previewSession.current.close() + previewSession.current = null + } return false } else if (unprocessedPercentage >= 0 && unprocessedPercentage <= 1 && !showMiniInspector) { setShowMiniInspector(true) diff --git a/packages/webui/src/client/ui/util/useSetDocumentClass.ts b/packages/webui/src/client/ui/util/useSetDocumentClass.ts index 0a09de6f768..74266aa3eeb 100644 --- a/packages/webui/src/client/ui/util/useSetDocumentClass.ts +++ b/packages/webui/src/client/ui/util/useSetDocumentClass.ts @@ -1,4 +1,4 @@ -import { useLayoutEffect } from 'react' +import { useEffect, useRef, useLayoutEffect } from 'react' /** * Adds the provided classes to `document.body` upon mount, and removes them when unmounted @@ -23,3 +23,36 @@ export function useSetDocumentDarkTheme(): void { } }, []) } + +/** + * Removes a class from an element on mount and adds it back on unmount, + * but only if this hook instance removed it. + */ +export function useOwnedElementClassToggle(selector: string, className: string, removeOnMount = true): void { + const removedByHookRef = useRef(false) + const ownedElementRef = useRef(null) + + useEffect(() => { + removedByHookRef.current = false + ownedElementRef.current = null + + const element = document.querySelector(selector) + if (element instanceof HTMLElement && element.isConnected && removeOnMount) { + removedByHookRef.current = element.classList.contains(className) + if (removedByHookRef.current) { + element.classList.remove(className) + ownedElementRef.current = element + } + } + + return () => { + const ownedElement = ownedElementRef.current + if (removedByHookRef.current && ownedElement && ownedElement.isConnected) { + ownedElement.classList.add(className) + } + + removedByHookRef.current = false + ownedElementRef.current = null + } + }, [selector, className, removeOnMount]) +} From c5f40702f5701ce6741864dcab2bd926087fd2b5 Mon Sep 17 00:00:00 2001 From: Peter C <12292660+PeterC89@users.noreply.github.com> Date: Mon, 8 Jun 2026 12:15:18 +0100 Subject: [PATCH 2/8] fix(webui): Prevent unexpected crash in VTSourceRenderer --- .../SegmentTimeline/Renderers/VTSourceRenderer.tsx | 14 ++++++++++++-- 1 file changed, 12 insertions(+), 2 deletions(-) diff --git a/packages/webui/src/client/ui/SegmentTimeline/Renderers/VTSourceRenderer.tsx b/packages/webui/src/client/ui/SegmentTimeline/Renderers/VTSourceRenderer.tsx index 68bd58c94d9..2b3857de599 100644 --- a/packages/webui/src/client/ui/SegmentTimeline/Renderers/VTSourceRenderer.tsx +++ b/packages/webui/src/client/ui/SegmentTimeline/Renderers/VTSourceRenderer.tsx @@ -210,11 +210,21 @@ class VTSourceRendererBase extends CustomLayerItemRenderer 0) { + if (this.hasStateChanges(newState)) { this.setState(newState as IState) } } + private hasStateChanges = (newState: Partial): boolean => { + for (const [key, value] of Object.entries(newState)) { + if (this.state[key as keyof IState] !== value) { + return true + } + } + + return false + } + private updateAnchoredElsWidths = () => { const leftLabelWidth = this.leftLabel ? getElementWidth(this.leftLabel) : 0 const rightLabelWidth = this.rightLabel ? getElementWidth(this.rightLabel) : 0 @@ -248,7 +258,7 @@ class VTSourceRendererBase extends CustomLayerItemRenderer 0) { + if (this.hasStateChanges(newState)) { this.setState(newState as IState, () => { if (newState.noticeLevel && newState.noticeLevel !== prevState.noticeLevel) { this.updateAnchoredElsWidths() From 2af65b96d867f8dc2b4f7e13ee727680ed7b5dbf Mon Sep 17 00:00:00 2001 From: Peter C <12292660+PeterC89@users.noreply.github.com> Date: Mon, 8 Jun 2026 12:26:02 +0100 Subject: [PATCH 3/8] fix: Coderabbit review comments --- packages/webui/src/client/lib/Escape.tsx | 8 +++++++- packages/webui/src/client/lib/viewPort.ts | 3 --- .../SegmentTimeline/Renderers/VTSourceRenderer.tsx | 12 ++++++++++-- 3 files changed, 17 insertions(+), 6 deletions(-) diff --git a/packages/webui/src/client/lib/Escape.tsx b/packages/webui/src/client/lib/Escape.tsx index 8d2b8a911ae..37e6494e23d 100644 --- a/packages/webui/src/client/lib/Escape.tsx +++ b/packages/webui/src/client/lib/Escape.tsx @@ -54,7 +54,6 @@ function usePortal(id: string, style?: Partial { // Only proceed if we're not already scrolling and within the time window if (!focusState.isScrolling && Date.now() - focusState.startTime < timeWindow) { focusState.isScrolling = true - viewPortScrollingState.lastProgrammaticScrollTime = Date.now() try { await scrollToPartInstance(partInstanceId, forceScroll, noAnimation) diff --git a/packages/webui/src/client/ui/SegmentTimeline/Renderers/VTSourceRenderer.tsx b/packages/webui/src/client/ui/SegmentTimeline/Renderers/VTSourceRenderer.tsx index 2b3857de599..1a52ac55ae2 100644 --- a/packages/webui/src/client/ui/SegmentTimeline/Renderers/VTSourceRenderer.tsx +++ b/packages/webui/src/client/ui/SegmentTimeline/Renderers/VTSourceRenderer.tsx @@ -216,8 +216,16 @@ class VTSourceRendererBase extends CustomLayerItemRenderer): boolean => { - for (const [key, value] of Object.entries(newState)) { - if (this.state[key as keyof IState] !== value) { + const keys: Array = [ + 'rightLabelIsAppendage', + 'noticeLevel', + 'begin', + 'end', + 'sourceEndCountdownAppendage', + ] + + for (const key of keys) { + if (Object.prototype.hasOwnProperty.call(newState, key) && this.state[key] !== newState[key]) { return true } } From bc89ede47b7ac1fb3876e306843e60857fab1389 Mon Sep 17 00:00:00 2001 From: Peter C <12292660+PeterC89@users.noreply.github.com> Date: Mon, 8 Jun 2026 12:30:27 +0100 Subject: [PATCH 4/8] fix: Coderabbit review comments --- packages/webui/src/client/lib/viewPort.ts | 4 +++- .../ui/SegmentTimeline/SourceLayerItem.tsx | 16 ++++++++-------- .../client/ui/SegmentTimeline/TimelineGrid.tsx | 1 + 3 files changed, 12 insertions(+), 9 deletions(-) diff --git a/packages/webui/src/client/lib/viewPort.ts b/packages/webui/src/client/lib/viewPort.ts index aa973d352b9..8f8c36376c8 100644 --- a/packages/webui/src/client/lib/viewPort.ts +++ b/packages/webui/src/client/lib/viewPort.ts @@ -257,7 +257,7 @@ async function innerScrollToSegment( clearTimeout(pendingFirstStageTimeout) pendingFirstStageTimeout = undefined } - currentScrollingElement = elementToScrollTo + currentScrollingElement = undefined } else if (secondStage && elementToScrollTo !== currentScrollingElement) { throw new Error('Scroll overriden by another scroll') } @@ -292,6 +292,7 @@ async function innerScrollToSegment( // If not in place atempt to scroll again innerScrollToSegment(elementToScrollTo, forceScroll, true, true).then(resolve, reject) } else { + currentScrollingElement = undefined resolve(true) } }, 1000) // When UI is getting optimized further we could lower this value @@ -309,6 +310,7 @@ async function innerScrollToSegment( ) } + currentScrollingElement = undefined return Promise.resolve(false) } diff --git a/packages/webui/src/client/ui/SegmentTimeline/SourceLayerItem.tsx b/packages/webui/src/client/ui/SegmentTimeline/SourceLayerItem.tsx index 1d51c667264..0605b0c604f 100644 --- a/packages/webui/src/client/ui/SegmentTimeline/SourceLayerItem.tsx +++ b/packages/webui/src/client/ui/SegmentTimeline/SourceLayerItem.tsx @@ -250,14 +250,6 @@ export const SourceLayerItem = (props: Readonly): JSX.Ele const previewContext = useContext(PreviewPopUpContext) const previewSession = useRef(null) - const toggleMiniInspectorOn = useCallback( - (e: React.MouseEvent) => togglePreviewPopUp(e, true), - [piece, cursorTimePosition, contentStatus, timeScale] - ) - const toggleMiniInspectorOff = useCallback( - (e: React.MouseEvent) => togglePreviewPopUp(e, false), - [piece, cursorTimePosition, contentStatus, timeScale] - ) const updatePos = useCallback(() => { const elementPos = getElementDocumentOffset(itemElementRef.current) || { top: 0, @@ -341,6 +333,14 @@ export const SourceLayerItem = (props: Readonly): JSX.Ele props.piece.renderedInPoint, ] ) + const toggleMiniInspectorOn = useCallback( + (e: React.MouseEvent) => togglePreviewPopUp(e, true), + [piece, cursorTimePosition, contentStatus, timeScale, togglePreviewPopUp] + ) + const toggleMiniInspectorOff = useCallback( + (e: React.MouseEvent) => togglePreviewPopUp(e, false), + [piece, cursorTimePosition, contentStatus, timeScale, togglePreviewPopUp] + ) const moveMiniInspector = useCallback((e: MouseEvent | any) => { cursorRawPosition.current = { clientX: e.clientX, diff --git a/packages/webui/src/client/ui/SegmentTimeline/TimelineGrid.tsx b/packages/webui/src/client/ui/SegmentTimeline/TimelineGrid.tsx index 460184bbf9f..69c0421aec5 100644 --- a/packages/webui/src/client/ui/SegmentTimeline/TimelineGrid.tsx +++ b/packages/webui/src/client/ui/SegmentTimeline/TimelineGrid.tsx @@ -446,6 +446,7 @@ export class TimelineGrid extends React.Component { window.cancelAnimationFrame(this.scheduledRepaint) this.scheduledRepaint = null } + this.contextResize.cancel() if (this._resizeObserver) this._resizeObserver.disconnect() window.removeEventListener(RundownTiming.Events.timeupdateLowResolution, this.onTimeupdate) window.removeEventListener(RundownTiming.Events.timeupdateHighResolution, this.onTimeupdate) From 3bbaa230f6de193bba39cc8ea613543ca6ffaca7 Mon Sep 17 00:00:00 2001 From: Peter C <12292660+PeterC89@users.noreply.github.com> Date: Mon, 8 Jun 2026 14:23:17 +0100 Subject: [PATCH 5/8] fix(webui): Migrate preview popup to Portal [copilot] --- .../client/ui/PreviewPopUp/PreviewPopUp.tsx | 6 +++- .../ui/PreviewPopUp/PreviewPopUpContext.tsx | 29 ++++++++++--------- 2 files changed, 21 insertions(+), 14 deletions(-) diff --git a/packages/webui/src/client/ui/PreviewPopUp/PreviewPopUp.tsx b/packages/webui/src/client/ui/PreviewPopUp/PreviewPopUp.tsx index 39562d93953..2a631028d6b 100644 --- a/packages/webui/src/client/ui/PreviewPopUp/PreviewPopUp.tsx +++ b/packages/webui/src/client/ui/PreviewPopUp/PreviewPopUp.tsx @@ -107,7 +107,11 @@ export const PreviewPopUp = React.forwardRef< return () => { anchorRef.current = null anchorYRef.current = 0 - virtualElement.current.getBoundingClientRect = generateGetBoundingClientRect(0, 0) + // Clear the virtualElement completely to break closure references + if (virtualElement.current) { + virtualElement.current.getBoundingClientRect = generateGetBoundingClientRect(0, 0) + virtualElement.current = null as any + } updateRef.current = null } }, []) diff --git a/packages/webui/src/client/ui/PreviewPopUp/PreviewPopUpContext.tsx b/packages/webui/src/client/ui/PreviewPopUp/PreviewPopUpContext.tsx index 57fcb58bd00..f6c51efebe5 100644 --- a/packages/webui/src/client/ui/PreviewPopUp/PreviewPopUpContext.tsx +++ b/packages/webui/src/client/ui/PreviewPopUp/PreviewPopUpContext.tsx @@ -1,5 +1,6 @@ import React, { useCallback, useEffect, useRef, useState } from 'react' import { PreviewPopUp, type PreviewPopUpHandle } from './PreviewPopUp.js' +import Escape from '../../lib/Escape.js' import type { Padding, Placement } from '@popperjs/core' import { PreviewPopUpContent } from './PreviewPopUpContent.js' import { @@ -510,19 +511,21 @@ export function PreviewPopUpContextProvider({ children }: React.PropsWithChildre {children} {previewSession && ( - - {previewContent && - previewContent.map((content, i) => )} - + + + {previewContent && + previewContent.map((content, i) => )} + + )} ) From ebcfb3f25266f018ce2e5650e06c9a249bebffc0 Mon Sep 17 00:00:00 2001 From: Peter C <12292660+PeterC89@users.noreply.github.com> Date: Mon, 8 Jun 2026 14:54:41 +0100 Subject: [PATCH 6/8] fix(webui): Try and fix last popup preview leaks [copilot] --- .../client/ui/PreviewPopUp/PreviewPopUp.tsx | 30 ++++++++++--------- .../ui/PreviewPopUp/PreviewPopUpContext.tsx | 11 +++++++ .../PreviewPopUp/Previews/IFramePreview.tsx | 30 ++++++++----------- .../Parts/InvalidPartCover.tsx | 8 ++--- .../usePreviewPopUpSession.ts | 3 ++ 5 files changed, 47 insertions(+), 35 deletions(-) diff --git a/packages/webui/src/client/ui/PreviewPopUp/PreviewPopUp.tsx b/packages/webui/src/client/ui/PreviewPopUp/PreviewPopUp.tsx index 2a631028d6b..15b17e40e71 100644 --- a/packages/webui/src/client/ui/PreviewPopUp/PreviewPopUp.tsx +++ b/packages/webui/src/client/ui/PreviewPopUp/PreviewPopUp.tsx @@ -60,11 +60,13 @@ export const PreviewPopUp = React.forwardRef< }), [padding] ) + const virtualPositionRef = useRef({ + x: initialOffsetX ?? anchor?.getBoundingClientRect().x ?? 0, + y: anchor?.getBoundingClientRect().y ?? 0, + }) const virtualElement = useRef({ - getBoundingClientRect: generateGetBoundingClientRect( - initialOffsetX ?? anchor?.getBoundingClientRect().x ?? 0, - anchor?.getBoundingClientRect().y ?? 0 - ), + getBoundingClientRect: () => + generateVirtualBoundingClientRect(virtualPositionRef.current.x, virtualPositionRef.current.y), }) const anchorRef = useRef(anchor) const anchorYRef = useRef(anchor?.getBoundingClientRect().y ?? 0) @@ -83,13 +85,17 @@ export const PreviewPopUp = React.forwardRef< useEffect(() => { anchorRef.current = anchor anchorYRef.current = anchor?.getBoundingClientRect().y ?? 0 - }, [anchor]) + virtualPositionRef.current = { + x: initialOffsetX ?? anchor?.getBoundingClientRect().x ?? 0, + y: anchor?.getBoundingClientRect().y ?? 0, + } + }, [anchor, initialOffsetX]) useEffect(() => { if (trackMouse) { const listener = ({ clientX: x }: MouseEvent) => { if (isDetachedHTMLElementAnchor(anchorRef.current)) return - virtualElement.current.getBoundingClientRect = generateGetBoundingClientRect(x, anchorYRef.current) + virtualPositionRef.current = { x, y: anchorYRef.current } // If update is available, call it to reposition the popper: if (updateRef.current) { updateRef.current().catch((e) => console.error(e)) @@ -107,11 +113,7 @@ export const PreviewPopUp = React.forwardRef< return () => { anchorRef.current = null anchorYRef.current = 0 - // Clear the virtualElement completely to break closure references - if (virtualElement.current) { - virtualElement.current.getBoundingClientRect = generateGetBoundingClientRect(0, 0) - virtualElement.current = null as any - } + virtualPositionRef.current = { x: 0, y: 0 } updateRef.current = null } }, []) @@ -145,8 +147,8 @@ export type PreviewPopUpHandle = { readonly update: () => void } -function generateGetBoundingClientRect(x = 0, y = 0) { - return () => ({ +function generateVirtualBoundingClientRect(x = 0, y = 0) { + return { width: 0, height: 0, x: x, @@ -156,5 +158,5 @@ function generateGetBoundingClientRect(x = 0, y = 0) { bottom: y, left: x, toJSON: () => '', - }) + } } diff --git a/packages/webui/src/client/ui/PreviewPopUp/PreviewPopUpContext.tsx b/packages/webui/src/client/ui/PreviewPopUp/PreviewPopUpContext.tsx index f6c51efebe5..5a684fefc87 100644 --- a/packages/webui/src/client/ui/PreviewPopUp/PreviewPopUpContext.tsx +++ b/packages/webui/src/client/ui/PreviewPopUp/PreviewPopUpContext.tsx @@ -379,6 +379,7 @@ export const PreviewPopUpContext = React.createContext({ }) interface PreviewSession { + token: number anchor: HTMLElement | VirtualElement padding: Padding placement: Placement @@ -391,6 +392,7 @@ export function PreviewPopUpContextProvider({ children }: React.PropsWithChildre const currentHandle = useRef() const previewRef = useRef(null) const closeSessionRef = useRef<() => void>(() => undefined) + const sessionTokenRef = useRef(0) const [previewSession, setPreviewSession] = useState(null) const [previewContent, setPreviewContent] = useState(null) @@ -402,6 +404,8 @@ export function PreviewPopUpContextProvider({ children }: React.PropsWithChildre } const closeSession = useCallback(() => { + sessionTokenRef.current += 1 + const previousHandle = currentHandle.current if (previousHandle) { currentHandle.current = undefined @@ -425,11 +429,15 @@ export function PreviewPopUpContextProvider({ children }: React.PropsWithChildre useEffect(() => { if (!previewSession) return + const token = previewSession.token const anchor = previewSession.anchor if (!(anchor instanceof HTMLElement)) return let rafHandle: number | undefined + let disposed = false const checkAnchorConnection = () => { + if (disposed) return + if (sessionTokenRef.current !== token) return if (!anchor.isConnected) { closeSessionRef.current() return @@ -440,6 +448,7 @@ export function PreviewPopUpContextProvider({ children }: React.PropsWithChildre rafHandle = window.requestAnimationFrame(checkAnchorConnection) return () => { + disposed = true if (rafHandle !== undefined) { window.cancelAnimationFrame(rafHandle) } @@ -460,6 +469,7 @@ export function PreviewPopUpContextProvider({ children }: React.PropsWithChildre closeSession() setPreviewSessionKey((prev) => prev + 1) + const token = ++sessionTokenRef.current if (opts?.time !== undefined) { setTime(opts.time) @@ -467,6 +477,7 @@ export function PreviewPopUpContextProvider({ children }: React.PropsWithChildre setTime(null) } setPreviewSession({ + token, anchor, padding: opts?.padding ?? 0, placement: opts?.placement ?? 'top', diff --git a/packages/webui/src/client/ui/PreviewPopUp/Previews/IFramePreview.tsx b/packages/webui/src/client/ui/PreviewPopUp/Previews/IFramePreview.tsx index b93067a262e..09b80a24dc8 100644 --- a/packages/webui/src/client/ui/PreviewPopUp/Previews/IFramePreview.tsx +++ b/packages/webui/src/client/ui/PreviewPopUp/Previews/IFramePreview.tsx @@ -1,4 +1,4 @@ -import { useCallback, useEffect, useRef } from 'react' +import { useEffect, useRef } from 'react' import { relativeToSiteRootUrl } from '../../../url.js' interface IFramePreviewProps { @@ -15,28 +15,24 @@ interface IFramePreviewProps { export function IFramePreview({ content }: IFramePreviewProps): React.ReactElement { const iFrameElement = useRef(null) - const onLoadListener = useCallback(() => { - if (content.postMessage) { - // use * as URL reference to avoid cors when posting message with new reference: - iFrameElement.current?.contentWindow?.postMessage(content.postMessage, '*') - } - }, [content.postMessage, content.href]) - useEffect(() => { - // Create a stable reference to the iframe element: const currentIFrame = iFrameElement.current if (!currentIFrame) return - currentIFrame.addEventListener('load', onLoadListener) - return () => currentIFrame.removeEventListener('load', onLoadListener) - }, [onLoadListener]) + const postMessageToIFrame = () => { + if (content.postMessage) { + // use * as URL reference to avoid cors when posting message with new reference: + currentIFrame.contentWindow?.postMessage(content.postMessage, '*') + } + } - // Handle postMessage updates when iframe is already loaded - useEffect(() => { - if (content.postMessage && iFrameElement.current?.contentWindow) { - // use * as URL reference to avoid cors when posting message with new reference: - iFrameElement.current.contentWindow.postMessage(content.postMessage, '*') + currentIFrame.addEventListener('load', postMessageToIFrame) + + if (currentIFrame.contentDocument?.readyState === 'complete') { + postMessageToIFrame() } + + return () => currentIFrame.removeEventListener('load', postMessageToIFrame) }, [content.postMessage, content.href]) const style: Record = {} diff --git a/packages/webui/src/client/ui/SegmentTimeline/Parts/InvalidPartCover.tsx b/packages/webui/src/client/ui/SegmentTimeline/Parts/InvalidPartCover.tsx index 0e9388850a3..4f82b73e241 100644 --- a/packages/webui/src/client/ui/SegmentTimeline/Parts/InvalidPartCover.tsx +++ b/packages/webui/src/client/ui/SegmentTimeline/Parts/InvalidPartCover.tsx @@ -1,4 +1,4 @@ -import React, { useContext, useEffect, useRef } from 'react' +import { useContext, useEffect, useRef } from 'react' import type { PartInvalidReason } from '@sofie-automation/corelib/dist/dataModel/Part' import { type IPreviewPopUpSession, PreviewPopUpContext } from '../../PreviewPopUp/PreviewPopUpContext.js' @@ -12,12 +12,12 @@ interface IProps { } export function InvalidPartCover({ className, invalidReason }: Readonly): JSX.Element { - const element = React.createRef() + const element = useRef(null) const previewContext = useContext(PreviewPopUpContext) const previewSession = useRef(null) - function onMouseEnter(e: React.MouseEvent) { + function onMouseEnter() { if (!element.current) { return } @@ -28,7 +28,7 @@ export function InvalidPartCover({ className, invalidReason }: Readonly) } if (invalidReason?.message && !previewSession.current) { - previewSession.current = previewContext.requestPreview(e.currentTarget, [ + previewSession.current = previewContext.requestPreview(element.current, [ { type: 'warning', content: invalidReason.message, diff --git a/packages/webui/src/client/ui/Shelf/DashboardPieceButton/usePreviewPopUpSession.ts b/packages/webui/src/client/ui/Shelf/DashboardPieceButton/usePreviewPopUpSession.ts index 2c96f11f07f..c552a71ef7a 100644 --- a/packages/webui/src/client/ui/Shelf/DashboardPieceButton/usePreviewPopUpSession.ts +++ b/packages/webui/src/client/ui/Shelf/DashboardPieceButton/usePreviewPopUpSession.ts @@ -25,9 +25,11 @@ export function usePreviewPopUpSession(args: { } { const previewSessionRef = useRef(null) const hoverTimeoutRef = useRef(null) + const isMountedRef = useRef(true) useEffect(() => { return () => { + isMountedRef.current = false if (hoverTimeoutRef.current) { Meteor.clearTimeout(hoverTimeoutRef.current) hoverTimeoutRef.current = null @@ -48,6 +50,7 @@ export function usePreviewPopUpSession(args: { const startHoverTimeout = useCallback(() => { if (hoverTimeoutRef.current) Meteor.clearTimeout(hoverTimeoutRef.current) hoverTimeoutRef.current = Meteor.setTimeout(() => { + if (!isMountedRef.current) return if (previewSessionRef.current) { previewSessionRef.current.close() previewSessionRef.current = null From 6a4693c729f7136f15b6cca0d5a631edb10f1ee9 Mon Sep 17 00:00:00 2001 From: Peter C <12292660+PeterC89@users.noreply.github.com> Date: Mon, 8 Jun 2026 15:14:25 +0100 Subject: [PATCH 7/8] fix(webui): Accidental regression --- packages/webui/src/client/lib/viewPort.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/webui/src/client/lib/viewPort.ts b/packages/webui/src/client/lib/viewPort.ts index 8f8c36376c8..9810b0a7fda 100644 --- a/packages/webui/src/client/lib/viewPort.ts +++ b/packages/webui/src/client/lib/viewPort.ts @@ -257,7 +257,7 @@ async function innerScrollToSegment( clearTimeout(pendingFirstStageTimeout) pendingFirstStageTimeout = undefined } - currentScrollingElement = undefined + currentScrollingElement = elementToScrollTo } else if (secondStage && elementToScrollTo !== currentScrollingElement) { throw new Error('Scroll overriden by another scroll') } From d58da6dc6491764e4fd3c03a0327840b39a6e79f Mon Sep 17 00:00:00 2001 From: Peter C <12292660+PeterC89@users.noreply.github.com> Date: Tue, 23 Jun 2026 09:48:34 +0100 Subject: [PATCH 8/8] fix: Merge issue --- packages/webui/src/client/ui/PreviewPopUp/PreviewPopUp.tsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/webui/src/client/ui/PreviewPopUp/PreviewPopUp.tsx b/packages/webui/src/client/ui/PreviewPopUp/PreviewPopUp.tsx index 94d91fcd4e0..aae98d36342 100644 --- a/packages/webui/src/client/ui/PreviewPopUp/PreviewPopUp.tsx +++ b/packages/webui/src/client/ui/PreviewPopUp/PreviewPopUp.tsx @@ -70,7 +70,7 @@ export const PreviewPopUp = React.forwardRef< [padding] ) const initialVirtualX = - trackMouse && typeof initialOffsetX === 'number' + (trackMouse && typeof initialOffsetX === 'number') ? initialOffsetX : anchor?.getBoundingClientRect().x ?? 0 const virtualPositionRef = useRef({