diff --git a/.changeset/calm-tooltips-rest.md b/.changeset/calm-tooltips-rest.md new file mode 100644 index 0000000000..1618087657 --- /dev/null +++ b/.changeset/calm-tooltips-rest.md @@ -0,0 +1,5 @@ +--- +"@codegraphy-dev/extension": patch +--- + +Stop stationary Node tooltips and hidden Graph Views from running graph frames. diff --git a/packages/extension/src/extension/graphView/editorPanel.ts b/packages/extension/src/extension/graphView/editorPanel.ts index 76007eebce..d44421ba00 100644 --- a/packages/extension/src/extension/graphView/editorPanel.ts +++ b/packages/extension/src/extension/graphView/editorPanel.ts @@ -1,4 +1,5 @@ import * as vscode from 'vscode'; +import { publishGraphViewVisibility } from './webview/visibility'; interface OpenGraphViewInEditorOptions { viewType: string; @@ -46,7 +47,11 @@ export function openGraphViewInEditor({ }; setWebviewMessageListener(panel.webview); panel.webview.html = getHtmlForWebview(panel.webview); + publishGraphViewVisibility(panel.webview, panel.visible); registerPanel(panel); + panel.onDidChangeViewState(({ webviewPanel }) => { + publishGraphViewVisibility(webviewPanel.webview, webviewPanel.visible); + }); panel.onDidDispose(() => { unregisterPanel(panel); }); diff --git a/packages/extension/src/extension/graphView/webview/resolve.ts b/packages/extension/src/extension/graphView/webview/resolve.ts index f46aae7d2c..2be0c9f67b 100644 --- a/packages/extension/src/extension/graphView/webview/resolve.ts +++ b/packages/extension/src/extension/graphView/webview/resolve.ts @@ -1,3 +1,5 @@ +import { publishGraphViewVisibility } from './visibility'; + interface GraphViewWebviewLike { options: { enableScripts?: boolean; @@ -5,6 +7,7 @@ interface GraphViewWebviewLike { retainContextWhenHidden?: boolean; }; html: string; + postMessage(message: unknown): unknown; } interface GraphViewWebviewViewLike { @@ -42,8 +45,10 @@ export function resolveGraphViewWebviewView( webviewView.webview.html = html; void executeCommand('setContext', 'codegraphy.viewVisible', webviewView.visible); + publishGraphViewVisibility(webviewView.webview, webviewView.visible); webviewView.onDidChangeVisibility(() => { void executeCommand('setContext', 'codegraphy.viewVisible', webviewView.visible); + publishGraphViewVisibility(webviewView.webview, webviewView.visible); }); } diff --git a/packages/extension/src/extension/graphView/webview/visibility.ts b/packages/extension/src/extension/graphView/webview/visibility.ts new file mode 100644 index 0000000000..619ae0517b --- /dev/null +++ b/packages/extension/src/extension/graphView/webview/visibility.ts @@ -0,0 +1,13 @@ +interface VisibilityWebview { + postMessage(message: unknown): unknown; +} + +export function publishGraphViewVisibility( + webview: VisibilityWebview, + visible: boolean, +): void { + void webview.postMessage({ + type: 'GRAPH_VIEW_VISIBILITY_UPDATED', + payload: { visible }, + }); +} diff --git a/packages/extension/src/shared/protocol/extensionToWebview.ts b/packages/extension/src/shared/protocol/extensionToWebview.ts index 57a8646794..aadac3a52d 100644 --- a/packages/extension/src/shared/protocol/extensionToWebview.ts +++ b/packages/extension/src/shared/protocol/extensionToWebview.ts @@ -24,6 +24,7 @@ export interface IGraphNodeMetricsUpdate { export type ExtensionToWebviewMessage = | { type: 'GRAPH_DATA_UPDATED'; payload: IGraphData } + | { type: 'GRAPH_VIEW_VISIBILITY_UPDATED'; payload: { visible: boolean } } | { type: 'GRAPH_NODE_METRICS_UPDATED'; payload: { nodes: IGraphNodeMetricsUpdate[] } } | { type: 'APP_BOOTSTRAP_COMPLETE' } | { diff --git a/packages/extension/src/webview/components/graph/contextMenuOpening/runtime.ts b/packages/extension/src/webview/components/graph/contextMenuOpening/runtime.ts index 419eb21c5f..17f5b8afde 100644 --- a/packages/extension/src/webview/components/graph/contextMenuOpening/runtime.ts +++ b/packages/extension/src/webview/components/graph/contextMenuOpening/runtime.ts @@ -60,7 +60,7 @@ export interface GraphContextMenuOpeningOptions { >; setContextSelection: Dispatch>; setTooltipData: GraphContextMenuRuntimeDependencies['setTooltipData']; - stopTooltipTracking: GraphContextMenuRuntimeDependencies['stopTooltipTracking']; + clearTooltipAnchor: GraphContextMenuRuntimeDependencies['clearTooltipAnchor']; tooltipTimeoutRef: GraphContextMenuRuntimeDependencies['tooltipTimeoutRef']; } @@ -75,7 +75,7 @@ function createGraphContextMenuOpeningDependencies({ refs, setContextSelection, setTooltipData, - stopTooltipTracking, + clearTooltipAnchor, tooltipTimeoutRef, }: Omit): GraphContextMenuRuntimeDependencies { return { @@ -99,7 +99,7 @@ function createGraphContextMenuOpeningDependencies({ }, setContextSelection, setTooltipData, - stopTooltipTracking, + clearTooltipAnchor, toggleFavoritesOptimistically: paths => graphStore.getState().toggleFavoritesOptimistically(paths), }; diff --git a/packages/extension/src/webview/components/graph/contextMenuRuntime/controller.ts b/packages/extension/src/webview/components/graph/contextMenuRuntime/controller.ts index adaa4a4c68..23923395dd 100644 --- a/packages/extension/src/webview/components/graph/contextMenuRuntime/controller.ts +++ b/packages/extension/src/webview/components/graph/contextMenuRuntime/controller.ts @@ -55,7 +55,7 @@ export interface GraphContextMenuRuntimeDependencies { refreshContextSelection?(): void; setContextSelection(selection: GraphContextSelection): void; setTooltipData(updater: (previousState: GraphTooltipState) => GraphTooltipState): void; - stopTooltipTracking(): void; + clearTooltipAnchor(): void; toggleFavoritesOptimistically?(paths: readonly string[]): void; now?(): number; fallbackDelayMs?: number; diff --git a/packages/extension/src/webview/components/graph/contextMenuRuntime/tooltip.ts b/packages/extension/src/webview/components/graph/contextMenuRuntime/tooltip.ts index db4861f8d0..42d997dbe8 100644 --- a/packages/extension/src/webview/components/graph/contextMenuRuntime/tooltip.ts +++ b/packages/extension/src/webview/components/graph/contextMenuRuntime/tooltip.ts @@ -14,7 +14,7 @@ type GraphContextMenuTooltipDependencies = Pick< | 'tooltipTimeoutRef' | 'setContextSelection' | 'setTooltipData' - | 'stopTooltipTracking' + | 'clearTooltipAnchor' > & Partial, ): OwnedGraphFrameLoop { let active = true; + let hostVisible = runtime.propsRef.current.graphViewVisible; + let documentVisible = document.visibilityState !== 'hidden'; + const isVisible = (): boolean => hostVisible && documentVisible; const frameRuntime: OwnedGraphFrameRuntime = { ...runtime, recordRenderedFrame(submissionId, timestamp, simulationMs, renderMs) { @@ -54,17 +63,34 @@ export function startOwnedGraphFrameLoop( const renderFrame = (timestamp: number): void => { runtime.animationFrameRef.current = null; + if (!active || !isVisible()) return; runtime.frameRequestedRef.current = false; - if (!active) return; renderOwnedGraphFrame(frameRuntime, canvas, timestamp); }; + const scheduleRequestedFrame = (): void => { + if (canScheduleFrame(runtime, active, isVisible())) { + runtime.animationFrameRef.current = window.requestAnimationFrame(renderFrame); + } + }; + runtime.requestFrameRef.current = () => { runtime.frameRequestedRef.current = true; - if (canScheduleFrame(runtime, active)) { - runtime.animationFrameRef.current = window.requestAnimationFrame(renderFrame); + scheduleRequestedFrame(); + }; + + const applyVisibility = (): void => { + if (!isVisible() && runtime.animationFrameRef.current !== null) { + window.cancelAnimationFrame(runtime.animationFrameRef.current); + runtime.animationFrameRef.current = null; } + if (isVisible() && runtime.frameRequestedRef.current) scheduleRequestedFrame(); }; + const handleVisibilityChange = (): void => { + documentVisible = document.visibilityState !== 'hidden'; + applyVisibility(); + }; + document.addEventListener('visibilitychange', handleVisibilityChange); const controls = createOwnedGraphControls(runtime, canvas); controlsRef.current = controls; @@ -76,8 +102,13 @@ export function startOwnedGraphFrameLoop( runtime.requestFrameRef.current(); return { + setHostVisible: (visible) => { + hostVisible = visible; + applyVisibility(); + }, dispose: () => { active = false; + document.removeEventListener('visibilitychange', handleVisibilityChange); resizeObserver.disconnect(); stopObservingDevicePixelRatio(); if (runtime.animationFrameRef.current !== null) { diff --git a/packages/extension/src/webview/components/graph/rendering/surface/owned2d/view/surface/contracts.ts b/packages/extension/src/webview/components/graph/rendering/surface/owned2d/view/surface/contracts.ts index af71289cc4..f8b535a836 100644 --- a/packages/extension/src/webview/components/graph/rendering/surface/owned2d/view/surface/contracts.ts +++ b/packages/extension/src/webview/components/graph/rendering/surface/owned2d/view/surface/contracts.ts @@ -36,6 +36,7 @@ export interface Surface2dProps { directionMode: DirectionMode; fg2dRef: MutableRefObject; graphViewContributions?: ExtensionGraphViewContributionSet; + graphViewVisible: boolean; getBaseLinkColor: (this: void, link: FGLink) => string; getBaseLinkOpacity: (this: void, link: FGLink) => number; getBaseLinkWidth: (this: void, link: FGLink) => number; diff --git a/packages/extension/src/webview/components/graph/rendering/surface/owned2d/view/surface/render.tsx b/packages/extension/src/webview/components/graph/rendering/surface/owned2d/view/surface/render.tsx index 2155973160..37a6d1dfc1 100644 --- a/packages/extension/src/webview/components/graph/rendering/surface/owned2d/view/surface/render.tsx +++ b/packages/extension/src/webview/components/graph/rendering/surface/owned2d/view/surface/render.tsx @@ -1,6 +1,7 @@ import { useCallback, useEffect, + useLayoutEffect, useRef, useState, type MutableRefObject, @@ -155,6 +156,7 @@ export function OwnedGraphSurface2d(props: Surface2dProps): ReactElement { createOwnedGraphPerformanceMonitor, ); const requestFrameRef = useRef<() => void>(NOOP); + const frameLoopRef = useRef | null>(null); const contextGestureSessionRef = useRef(null); const pointerSessionRef = useRef(null); const hoveredNodeRef = useRef(null); @@ -254,7 +256,11 @@ export function OwnedGraphSurface2d(props: Surface2dProps): ReactElement { }, }; const frameLoop = startOwnedGraphFrameLoop(frameLoopRuntime, canvas, props.fg2dRef); - return () => frameLoop.dispose(); + frameLoopRef.current = frameLoop; + return () => { + frameLoopRef.current = null; + frameLoop.dispose(); + }; }, [ cameraRef, clearLinkHover, @@ -266,6 +272,10 @@ export function OwnedGraphSurface2d(props: Surface2dProps): ReactElement { simulationClockRef, ]); + useLayoutEffect(() => { + frameLoopRef.current?.setHostVisible(props.graphViewVisible); + }, [props.graphViewVisible]); + useEffect(() => { clearLinkHover(); reconcileOwnedGraphRuntime(layoutRuntime); diff --git a/packages/extension/src/webview/components/graph/runtime/tooltip/hover.ts b/packages/extension/src/webview/components/graph/runtime/tooltip/hover.ts index 204ce851e3..1c9615aa9d 100644 --- a/packages/extension/src/webview/components/graph/runtime/tooltip/hover.ts +++ b/packages/extension/src/webview/components/graph/runtime/tooltip/hover.ts @@ -23,8 +23,8 @@ export interface TooltipHoverOptions { pluginHost?: WebviewPluginHost; postMessage(this: void, message: WebviewToExtensionMessage): void; setTooltipData: React.Dispatch>; - startTracking(this: void): void; - stopTracking(this: void): void; + initializeAnchorSnapshot(this: void): void; + clearAnchorSnapshot(this: void): void; tooltipTimeoutRef: MutableRefObject | null>; } @@ -32,11 +32,11 @@ function clearTooltipHoverState( hoveredNodeRef: MutableRefObject, interactionHandlers: GraphTooltipInteractionDependencies, setTooltipData: React.Dispatch>, - stopTracking: () => void, + clearAnchorSnapshot: () => void, ): void { interactionHandlers.setGraphCursor('default'); hoveredNodeRef.current = null; - stopTracking(); + clearAnchorSnapshot(); setTooltipData(hideGraphTooltipState); } @@ -52,8 +52,8 @@ export function handleTooltipNodeHover( pluginHost, postMessage, setTooltipData, - startTracking, - stopTracking, + initializeAnchorSnapshot, + clearAnchorSnapshot, tooltipTimeoutRef, }: TooltipHoverOptions, ): void { @@ -64,7 +64,7 @@ export function handleTooltipNodeHover( hoveredNodeRef, interactionHandlers, setTooltipData, - stopTracking, + clearAnchorSnapshot, ); return; } @@ -80,7 +80,7 @@ export function handleTooltipNodeHover( pluginHost, postMessage, setTooltipData, - startTracking, + initializeAnchorSnapshot, tooltipTimeoutRef, }); } diff --git a/packages/extension/src/webview/components/graph/runtime/tooltip/schedule.ts b/packages/extension/src/webview/components/graph/runtime/tooltip/schedule.ts index 1085e90da3..97bcdec81d 100644 --- a/packages/extension/src/webview/components/graph/runtime/tooltip/schedule.ts +++ b/packages/extension/src/webview/components/graph/runtime/tooltip/schedule.ts @@ -10,7 +10,7 @@ function shouldRequestFileInfo(node: FGNode): boolean { export function scheduleTooltipHover(node: FGNode, options: Pick): void { + | 'legends' | 'setTooltipData' | 'initializeAnchorSnapshot' | 'tooltipTimeoutRef'>): void { options.tooltipTimeoutRef.current = setTimeout(() => { const pluginTooltip = options.pluginHost?.getTooltipContent(buildGraphTooltipContext({ node, snapshot: options.dataRef.current })); const tooltipState = buildGraphTooltipState({ @@ -23,6 +23,6 @@ export function scheduleTooltipHover(node: FGNode, options: Pick; - setTooltipData: React.Dispatch>; - tooltipRafRef: MutableRefObject; + tooltipRectRef: MutableRefObject; } -export function stopTooltipTracking( - tooltipRafRef: MutableRefObject, +export function clearTooltipAnchorSnapshot( + tooltipRectRef: MutableRefObject, ): void { - if (tooltipRafRef.current !== null) { - cancelAnimationFrame(tooltipRafRef.current); - tooltipRafRef.current = null; - } + tooltipRectRef.current = null; } -export function startTooltipTracking({ +export function initializeTooltipAnchorSnapshot({ getNodeRect, hoveredNodeRef, - setTooltipData, - tooltipRafRef, + tooltipRectRef, }: GraphTooltipTrackingOptions): void { - const tick = (): void => { - const node = hoveredNodeRef.current; - if (!node) return; + const node = hoveredNodeRef.current; + tooltipRectRef.current = node ? getNodeRect(node) : null; +} - const rect = getNodeRect(node); - if (rect) { - setTooltipData(previous => previous.visible ? { ...previous, nodeRect: rect } : previous); - } +function rectsEqual( + first: GraphTooltipRect, + second: GraphTooltipRect, +): boolean { + return first.x === second.x + && first.y === second.y + && first.radius === second.radius; +} - tooltipRafRef.current = requestAnimationFrame(tick); - }; +export function updateTooltipAnchorSnapshot({ + getNodeRect, + hoveredNodeRef, + setTooltipData, + tooltipRectRef, +}: GraphTooltipTrackingOptions & { + setTooltipData: React.Dispatch>; +}): void { + const node = hoveredNodeRef.current; + const rect = node ? getNodeRect(node) : null; + if (!rect) { + clearTooltipAnchorSnapshot(tooltipRectRef); + return; + } + if (tooltipRectRef.current && rectsEqual(tooltipRectRef.current, rect)) return; - tooltipRafRef.current = requestAnimationFrame(tick); + tooltipRectRef.current = rect; + setTooltipData(previous => previous.visible + ? { ...previous, nodeRect: rect } + : previous); } diff --git a/packages/extension/src/webview/components/graph/runtime/use/interaction/contracts.ts b/packages/extension/src/webview/components/graph/runtime/use/interaction/contracts.ts index 7b1d86fe80..f7c97d2485 100644 --- a/packages/extension/src/webview/components/graph/runtime/use/interaction/contracts.ts +++ b/packages/extension/src/webview/components/graph/runtime/use/interaction/contracts.ts @@ -24,6 +24,7 @@ export interface UseGraphInteractionRuntimeOptions { graphContextSelection: GraphContextSelection; graphCursorRef: MutableRefObject; graphDataRef: GraphRuntime['renderer']['graphDataRef']; + graphViewVisible: boolean; graphViewContributions?: ExtensionGraphViewContributionSet; highlightedNeighborsRef: GraphRuntime['highlightedNeighborsRef']; highlightedNodeRef: GraphRuntime['highlightedNodeRef']; @@ -66,7 +67,8 @@ export interface UseGraphInteractionRuntimeResult { interactionHandlers: GraphInteractionHandlersRuntime; marqueeSelection: import('../../../marqueeSelection/model').GraphMarqueeSelectionState | null; setTooltipData: ReturnType['setTooltipData']; - stopTooltipTracking: ReturnType['stopTooltipTracking']; + clearTooltipAnchor: ReturnType['clearTooltipAnchor']; tooltipData: ReturnType['tooltipData']; tooltipTimeoutRef: ReturnType['tooltipTimeoutRef']; + updateTooltipAnchor: ReturnType['updateTooltipAnchor']; } diff --git a/packages/extension/src/webview/components/graph/runtime/use/interaction/hook.ts b/packages/extension/src/webview/components/graph/runtime/use/interaction/hook.ts index 107b6233a8..2319a63748 100644 --- a/packages/extension/src/webview/components/graph/runtime/use/interaction/hook.ts +++ b/packages/extension/src/webview/components/graph/runtime/use/interaction/hook.ts @@ -38,6 +38,7 @@ export function useGraphInteractionRuntime({ graphContextSelection, graphCursorRef, graphDataRef, + graphViewVisible, graphViewContributions, highlightedNeighborsRef, highlightedNodeRef, @@ -108,15 +109,17 @@ export function useGraphInteractionRuntime({ handleNodeHover, hoveredNodeRef, setTooltipData, - stopTooltipTracking, + clearTooltipAnchor, tooltipData, tooltipTimeoutRef, + updateTooltipAnchor, } = useGraphTooltip({ containerRef: refs.containerRef, dataRef, fg2dRef: refs.fg2dRef, fileInfoCacheRef, interactionHandlers, + graphViewVisible, legends, pluginHost, postMessage, @@ -135,6 +138,7 @@ export function useGraphInteractionRuntime({ interactionHandlers, selectedNodesSetRef: refs.selectedNodesSetRef, }); + const { clearMarqueeSelection } = marqueeRuntime; const getActionContext = useCallback( (selection: GraphContextSelection) => resolveGraphContextActionContext(selection, { @@ -144,22 +148,27 @@ export function useGraphInteractionRuntime({ [graphDataRef, refs.fg2dRef], ); - function handleNodeDragEnd(node: FGNode): void { + const handleNodeDragEnd = useCallback((node: FGNode): void => { postDraggedNodesDragEndMessages(node, nodeDragGroupRef.current, { graphData: graphDataRef.current, graphViewContributions, }); nodeDragGroupRef.current = null; - } + }, [graphDataRef, graphViewContributions]); - function handleNodeDrag(node: FGNode, translate: NodeDragTranslate): void { + const handleNodeDrag = useCallback((node: FGNode, translate: NodeDragTranslate): void => { nodeDragGroupRef.current = applyNodeDrag(node, translate, { graphData: graphDataRef.current, selectedNodeIds: refs.selectedNodesSetRef.current, }, nodeDragGroupRef.current); - marqueeRuntime.clearMarqueeSelection(); - } + clearMarqueeSelection(); + }, [clearMarqueeSelection, graphDataRef, refs.selectedNodesSetRef]); + + const handleEngineStop = useCallback( + () => postMessage({ type: 'PHYSICS_STABILIZED' }), + [], + ); const contextMenuOpeningRuntime = useMemo( () => createGraphContextMenuOpeningRuntime({ @@ -174,7 +183,7 @@ export function useGraphInteractionRuntime({ refs, setContextSelection: setLiveContextSelection, setTooltipData, - stopTooltipTracking, + clearTooltipAnchor, tooltipTimeoutRef, }), [ @@ -189,7 +198,7 @@ export function useGraphInteractionRuntime({ refs, setLiveContextSelection, setTooltipData, - stopTooltipTracking, + clearTooltipAnchor, tooltipTimeoutRef, ], ); @@ -244,7 +253,7 @@ export function useGraphInteractionRuntime({ ...contextMenuOpeningRuntime, handleBackgroundRightClick: suppressedContextMenuHandlers.handleBackgroundRightClick, handleContextMenu: suppressedContextMenuHandlers.handleContextMenu, - handleEngineStop: () => postMessage({ type: 'PHYSICS_STABILIZED' }), + handleEngineStop, handleLinkRightClick: suppressedContextMenuHandlers.handleLinkRightClick, handleMouseLeave: handleGraphMouseLeave, handleNodeHover, @@ -258,8 +267,9 @@ export function useGraphInteractionRuntime({ interactionHandlers, marqueeSelection: marqueeRuntime.marqueeSelection, setTooltipData, - stopTooltipTracking, + clearTooltipAnchor, tooltipData, tooltipTimeoutRef, + updateTooltipAnchor, }; } diff --git a/packages/extension/src/webview/components/graph/runtime/use/interaction/marquee/hook.ts b/packages/extension/src/webview/components/graph/runtime/use/interaction/marquee/hook.ts index 51278fe026..559ea9816b 100644 --- a/packages/extension/src/webview/components/graph/runtime/use/interaction/marquee/hook.ts +++ b/packages/extension/src/webview/components/graph/runtime/use/interaction/marquee/hook.ts @@ -1,4 +1,5 @@ import { + useCallback, useRef, useState, type MouseEvent as ReactMouseEvent, @@ -24,10 +25,10 @@ export function useGraphMarqueeSelectionRuntime( const marqueeDragRef = useRef(null); const [marqueeSelection, setMarqueeSelection] = useState(null); - function clearMarqueeSelection(): void { + const clearMarqueeSelection = useCallback((): void => { marqueeDragRef.current = null; setMarqueeSelection(null); - } + }, []); function handleMouseDownCapture(event: ReactMouseEvent): void { if (!canStartMarqueeSelection(event, options.hoveredNodeRef.current)) { diff --git a/packages/extension/src/webview/components/graph/runtime/use/tooltip/events/handlers.ts b/packages/extension/src/webview/components/graph/runtime/use/tooltip/events/handlers.ts index 6c4aa46e5f..f076471d03 100644 --- a/packages/extension/src/webview/components/graph/runtime/use/tooltip/events/handlers.ts +++ b/packages/extension/src/webview/components/graph/runtime/use/tooltip/events/handlers.ts @@ -1,5 +1,8 @@ import { + useCallback, useEffect, + useLayoutEffect, + useRef, type MutableRefObject, } from 'react'; import type { OwnedGraph2dControls } from '../../../../rendering/surface/owned2d/view/surface/contracts'; @@ -8,14 +11,15 @@ import type { IGraphData } from '../../../../../../../shared/graph/contracts'; import type { WebviewToExtensionMessage } from '../../../../../../../shared/protocol/webviewToExtension'; import type { IGroup } from '../../../../../../../shared/settings/groups'; import type { FGNode } from '../../../../model/build'; -import type { GraphTooltipState } from '../../../../tooltip/model'; +import type { GraphTooltipRect, GraphTooltipState } from '../../../../tooltip/model'; import type { WebviewPluginHost } from '../../../../../../pluginHost/manager'; import type { GraphTooltipInteractionDependencies } from '../hook'; import { handleTooltipNodeHover } from '../../../tooltip/hover'; import { getTooltipNodeRect } from '../../../tooltip/rect'; import { - startTooltipTracking as beginTooltipTracking, - stopTooltipTracking as endTooltipTracking, + clearTooltipAnchorSnapshot, + initializeTooltipAnchorSnapshot, + updateTooltipAnchorSnapshot, } from '../../../tooltip/tracking'; export interface UseTooltipEventsOptions { @@ -29,83 +33,97 @@ export interface UseTooltipEventsOptions { pluginHost?: WebviewPluginHost; postMessage: (this: void, message: WebviewToExtensionMessage) => void; setTooltipData: React.Dispatch>; - tooltipRafRef: MutableRefObject; + tooltipRectRef: MutableRefObject; tooltipTimeoutRef: MutableRefObject | null>; + graphViewVisible: boolean; } export interface UseTooltipEventsResult { handleMouseLeave: (this: void) => void; handleNodeHover: (this: void, node: FGNode | null) => void; - stopTooltipTracking: (this: void) => void; + clearTooltipAnchor: (this: void) => void; + updateTooltipAnchor: (this: void) => void; } -export function useTooltipEvents({ - containerRef, - dataRef, - fg2dRef, - fileInfoCacheRef, - hoveredNodeRef, - interactionHandlers, - legends, - pluginHost, - postMessage, - setTooltipData, - tooltipRafRef, - tooltipTimeoutRef, -}: UseTooltipEventsOptions): UseTooltipEventsResult { - const getNodeScreenRect = (node: FGNode) => getTooltipNodeRect({ - containerRef, - fg2dRef, - }, node); +export function useTooltipEvents(options: UseTooltipEventsOptions): UseTooltipEventsResult { + const optionsRef = useRef(options); + useLayoutEffect(() => { + optionsRef.current = options; + }, [options]); + const getNodeScreenRect = useCallback((node: FGNode) => { + const current = optionsRef.current; + return getTooltipNodeRect({ + containerRef: current.containerRef, + fg2dRef: current.fg2dRef, + }, node); + }, []); - const stopTooltipTracking = () => { - endTooltipTracking(tooltipRafRef); - }; + const clearTooltipAnchor = useCallback(() => { + clearTooltipAnchorSnapshot(optionsRef.current.tooltipRectRef); + }, []); - const startTooltipTracking = () => { - beginTooltipTracking({ + const initializeTooltipAnchor = useCallback(() => { + const current = optionsRef.current; + initializeTooltipAnchorSnapshot({ getNodeRect: getNodeScreenRect, - hoveredNodeRef, - setTooltipData, - tooltipRafRef, + hoveredNodeRef: current.hoveredNodeRef, + tooltipRectRef: current.tooltipRectRef, }); - }; + }, [getNodeScreenRect]); - const handleNodeHover = (node: FGNode | null) => { + const handleNodeHover = useCallback((node: FGNode | null) => { + const current = optionsRef.current; handleTooltipNodeHover(node, { - dataRef, - fileInfoCacheRef, + ...current, getNodeRect: getNodeScreenRect, - hoveredNodeRef, - interactionHandlers, - legends, - pluginHost, - postMessage, - setTooltipData, - startTracking: startTooltipTracking, - stopTracking: stopTooltipTracking, - tooltipTimeoutRef, + initializeAnchorSnapshot: initializeTooltipAnchor, + clearAnchorSnapshot: clearTooltipAnchor, }); - }; + }, [getNodeScreenRect, initializeTooltipAnchor, clearTooltipAnchor]); - const handleMouseLeave = () => { - interactionHandlers.setGraphCursor('default'); - }; + const handleMouseLeave = useCallback(() => { + handleNodeHover(null); + }, [handleNodeHover]); + + const updateTooltipAnchor = useCallback(() => { + const current = optionsRef.current; + updateTooltipAnchorSnapshot({ + getNodeRect: getNodeScreenRect, + hoveredNodeRef: current.hoveredNodeRef, + setTooltipData: current.setTooltipData, + tooltipRectRef: current.tooltipRectRef, + }); + }, [getNodeScreenRect]); useEffect( - () => () => { - if (tooltipTimeoutRef.current) { - clearTimeout(tooltipTimeoutRef.current); - tooltipTimeoutRef.current = null; - } - endTooltipTracking(tooltipRafRef); + () => { + const stopForVisibility = (): void => { + if (document.visibilityState === 'hidden') handleNodeHover(null); + }; + window.addEventListener('blur', handleMouseLeave); + document.addEventListener('visibilitychange', stopForVisibility); + return () => { + window.removeEventListener('blur', handleMouseLeave); + document.removeEventListener('visibilitychange', stopForVisibility); + const current = optionsRef.current; + if (current.tooltipTimeoutRef.current) { + clearTimeout(current.tooltipTimeoutRef.current); + current.tooltipTimeoutRef.current = null; + } + clearTooltipAnchorSnapshot(current.tooltipRectRef); + }; }, - [tooltipRafRef, tooltipTimeoutRef], + [handleMouseLeave, handleNodeHover], ); + useEffect(() => { + if (!options.graphViewVisible) handleNodeHover(null); + }, [handleNodeHover, options.graphViewVisible]); + return { handleMouseLeave, handleNodeHover, - stopTooltipTracking, + clearTooltipAnchor, + updateTooltipAnchor, }; } diff --git a/packages/extension/src/webview/components/graph/runtime/use/tooltip/events/state.ts b/packages/extension/src/webview/components/graph/runtime/use/tooltip/events/state.ts index 802afd4069..86107119a4 100644 --- a/packages/extension/src/webview/components/graph/runtime/use/tooltip/events/state.ts +++ b/packages/extension/src/webview/components/graph/runtime/use/tooltip/events/state.ts @@ -4,20 +4,20 @@ import { } from 'react'; import type { MutableRefObject } from 'react'; import type { FGNode } from '../../../../model/build'; -import type { GraphTooltipState } from '../../../../tooltip/model'; +import type { GraphTooltipRect, GraphTooltipState } from '../../../../tooltip/model'; export interface UseTooltipStateResult { hoveredNodeRef: MutableRefObject; setTooltipData: React.Dispatch>; tooltipData: GraphTooltipState; - tooltipRafRef: MutableRefObject; + tooltipRectRef: MutableRefObject; tooltipTimeoutRef: MutableRefObject | null>; } export function useTooltipState(): UseTooltipStateResult { const hoveredNodeRef = useRef(null); const tooltipTimeoutRef = useRef | null>(null); - const tooltipRafRef = useRef(null); + const tooltipRectRef = useRef(null); const [tooltipData, setTooltipData] = useState({ visible: false, nodeRect: { x: 0, y: 0, radius: 0 }, @@ -33,7 +33,7 @@ export function useTooltipState(): UseTooltipStateResult { hoveredNodeRef, setTooltipData, tooltipData, - tooltipRafRef, + tooltipRectRef, tooltipTimeoutRef, }; } diff --git a/packages/extension/src/webview/components/graph/runtime/use/tooltip/hook.ts b/packages/extension/src/webview/components/graph/runtime/use/tooltip/hook.ts index 1dbef5a8e0..0fde5ab0eb 100644 --- a/packages/extension/src/webview/components/graph/runtime/use/tooltip/hook.ts +++ b/packages/extension/src/webview/components/graph/runtime/use/tooltip/hook.ts @@ -24,6 +24,7 @@ export interface UseGraphTooltipOptions { legends?: readonly IGroup[]; pluginHost?: WebviewPluginHost; postMessage: (this: void, message: WebviewToExtensionMessage) => void; + graphViewVisible: boolean; } export interface UseGraphTooltipResult { @@ -31,9 +32,10 @@ export interface UseGraphTooltipResult { handleNodeHover: (this: void, node: FGNode | null) => void; hoveredNodeRef: MutableRefObject; setTooltipData: React.Dispatch>; - stopTooltipTracking: (this: void) => void; + clearTooltipAnchor: (this: void) => void; tooltipData: GraphTooltipState; tooltipTimeoutRef: MutableRefObject | null>; + updateTooltipAnchor: (this: void) => void; } export function useGraphTooltip({ @@ -45,18 +47,20 @@ export function useGraphTooltip({ legends, pluginHost, postMessage, + graphViewVisible, }: UseGraphTooltipOptions): UseGraphTooltipResult { const { hoveredNodeRef, setTooltipData, tooltipData, - tooltipRafRef, + tooltipRectRef, tooltipTimeoutRef, } = useTooltipState(); const { handleMouseLeave, handleNodeHover, - stopTooltipTracking, + clearTooltipAnchor, + updateTooltipAnchor, } = useTooltipEvents({ containerRef, dataRef, @@ -68,8 +72,9 @@ export function useGraphTooltip({ pluginHost, postMessage, setTooltipData, - tooltipRafRef, + tooltipRectRef, tooltipTimeoutRef, + graphViewVisible, }); return { @@ -77,8 +82,9 @@ export function useGraphTooltip({ handleNodeHover, hoveredNodeRef, setTooltipData, - stopTooltipTracking, + clearTooltipAnchor, tooltipData, tooltipTimeoutRef, + updateTooltipAnchor, }; } diff --git a/packages/extension/src/webview/components/graph/view/component.tsx b/packages/extension/src/webview/components/graph/view/component.tsx index 52feab40f2..5c644f6124 100644 --- a/packages/extension/src/webview/components/graph/view/component.tsx +++ b/packages/extension/src/webview/components/graph/view/component.tsx @@ -100,6 +100,7 @@ export default function Graph({ graphContextSelection: graphRuntime.context.selection, graphCursorRef: graphRuntime.graphCursorRef, graphDataRef: graphRuntime.renderer.graphDataRef, + graphViewVisible: viewState.graphViewVisible, graphViewContributions, highlightedNeighborsRef: graphRuntime.highlightedNeighborsRef, highlightedNodeRef: graphRuntime.highlightedNodeRef, diff --git a/packages/extension/src/webview/components/graph/view/store.ts b/packages/extension/src/webview/components/graph/view/store.ts index 9da942a9bc..c981aeb02e 100644 --- a/packages/extension/src/webview/components/graph/view/store.ts +++ b/packages/extension/src/webview/components/graph/view/store.ts @@ -6,6 +6,7 @@ export type GraphViewStoreState = Pick< | 'bidirectionalMode' | 'directionMode' | 'favorites' + | 'graphViewVisible' | 'nodeSizeMode' | 'particleSize' | 'particleSpeed' @@ -22,6 +23,7 @@ export function useGraphViewStoreState(): GraphViewStoreState { depthMode: useGraphStore(state => state.depthMode), directionMode: useGraphStore(state => state.directionMode), favorites: useGraphStore(state => state.favorites), + graphViewVisible: useGraphStore(state => state.graphViewVisible), nodeSizeMode: useGraphStore(state => state.nodeSizeMode), particleSize: useGraphStore(state => state.particleSize), particleSpeed: useGraphStore(state => state.particleSpeed), diff --git a/packages/extension/src/webview/components/graph/viewport/model.ts b/packages/extension/src/webview/components/graph/viewport/model.ts index ed000e8c97..267d25fee1 100644 --- a/packages/extension/src/webview/components/graph/viewport/model.ts +++ b/packages/extension/src/webview/components/graph/viewport/model.ts @@ -62,7 +62,15 @@ export function useGraphViewportModel({ [ graphState.graphData, handleEngineStop, - interactions, + interactions.handleBackgroundRightClick, + interactions.handleLinkRightClick, + interactions.handleNodeDrag, + interactions.handleNodeDragEnd, + interactions.handleNodeHover, + interactions.handleNodeRightClick, + interactions.interactionHandlers.handleBackgroundClick, + interactions.interactionHandlers.handleLinkClick, + interactions.interactionHandlers.handleNodeClick, viewportRuntime.containerSize.width, ], ); diff --git a/packages/extension/src/webview/components/graph/viewport/shell.tsx b/packages/extension/src/webview/components/graph/viewport/shell.tsx index 15f8261350..6daca28b79 100644 --- a/packages/extension/src/webview/components/graph/viewport/shell.tsx +++ b/packages/extension/src/webview/components/graph/viewport/shell.tsx @@ -142,6 +142,7 @@ export function GraphViewportShell({ }; renderFramePostRef.current = (ctx, globalScale) => { + interactions.updateTooltipAnchor(); publishGraphViewportScale(globalScale); publishGraphViewViewportState(globalScale); publishGraphAccessibilityItems(); diff --git a/packages/extension/src/webview/components/graph/viewport/shell/surfaceProps.ts b/packages/extension/src/webview/components/graph/viewport/shell/surfaceProps.ts index 1b2e930daa..3e1bde6d11 100644 --- a/packages/extension/src/webview/components/graph/viewport/shell/surfaceProps.ts +++ b/packages/extension/src/webview/components/graph/viewport/shell/surfaceProps.ts @@ -12,7 +12,7 @@ export interface CreateGraphViewportSurfacePropsInput { sharedProps: ViewportProps['surface2dProps']['sharedProps']; viewState: Pick< GraphViewStoreState, - 'particleSize' | 'particleSpeed' | 'physicsSettings' | 'showFps' | 'showMinimap' + 'graphViewVisible' | 'particleSize' | 'particleSpeed' | 'physicsSettings' | 'showFps' | 'showMinimap' >; } @@ -28,6 +28,7 @@ export function createGraphViewportSurfaceProps({ surface2dProps: { fg2dRef: graphState.renderer.fg2dRef, graphViewContributions, + graphViewVisible: viewState.graphViewVisible, getBaseLinkColor: callbacks.getBaseLinkColor, getBaseLinkOpacity: callbacks.getBaseLinkOpacity, getBaseLinkWidth: callbacks.getBaseLinkWidth, diff --git a/packages/extension/src/webview/components/graph/viewport/surface/equality.ts b/packages/extension/src/webview/components/graph/viewport/surface/equality.ts index 0662b39357..8e18e7acf5 100644 --- a/packages/extension/src/webview/components/graph/viewport/surface/equality.ts +++ b/packages/extension/src/webview/components/graph/viewport/surface/equality.ts @@ -3,6 +3,7 @@ import type { ViewportSurfaceProps } from './view'; const SURFACE_2D_PROP_KEYS = [ 'fg2dRef', 'graphViewContributions', + 'graphViewVisible', 'getArrowColor', 'getLinkColor', 'getLinkOpacity', diff --git a/packages/extension/src/webview/store/initialState.ts b/packages/extension/src/webview/store/initialState.ts index 232fe4a887..11f2148162 100644 --- a/packages/extension/src/webview/store/initialState.ts +++ b/packages/extension/src/webview/store/initialState.ts @@ -5,6 +5,7 @@ import { DEFAULT_MAX_FILES, DEFAULT_SHOW_MINIMAP } from '../../shared/settings/d export const INITIAL_STATE: GraphStateFields = { graphData: null, + graphViewVisible: true, graphHasIndex: false, graphIndexFreshness: 'missing' as const, graphIndexDetail: null, diff --git a/packages/extension/src/webview/store/messageTypes.ts b/packages/extension/src/webview/store/messageTypes.ts index da8c21b6b9..19237e4e93 100644 --- a/packages/extension/src/webview/store/messageTypes.ts +++ b/packages/extension/src/webview/store/messageTypes.ts @@ -20,6 +20,7 @@ import type { /** All fields that the store can hold — used to type partial state updates. */ export interface IStoreFields { graphData: IGraphData | null; + graphViewVisible: boolean; graphHasIndex: boolean; graphIndexFreshness: 'fresh' | 'stale' | 'missing'; graphIndexDetail: string | null; diff --git a/packages/extension/src/webview/store/messages.ts b/packages/extension/src/webview/store/messages.ts index 577174613c..25ae9f74fd 100644 --- a/packages/extension/src/webview/store/messages.ts +++ b/packages/extension/src/webview/store/messages.ts @@ -33,6 +33,12 @@ export const MESSAGE_HANDLERS: Record< string, (msg: ExtensionToWebviewMessage, ctx: IHandlerContext) => PartialState | void > = { + GRAPH_VIEW_VISIBILITY_UPDATED: (msg) => ({ + graphViewVisible: (msg as Extract< + ExtensionToWebviewMessage, + { type: 'GRAPH_VIEW_VISIBILITY_UPDATED' } + >).payload.visible, + }), GRAPH_DATA_UPDATED: (msg, ctx) => handleGraphDataUpdated(msg as Extract, ctx), GRAPH_NODE_METRICS_UPDATED: (msg, ctx) => diff --git a/packages/extension/src/webview/store/state.ts b/packages/extension/src/webview/store/state.ts index ea04729a56..5020ec2a8e 100644 --- a/packages/extension/src/webview/store/state.ts +++ b/packages/extension/src/webview/store/state.ts @@ -24,6 +24,7 @@ import type { export interface GraphState { graphData: IGraphData | null; + graphViewVisible: boolean; graphHasIndex: boolean; graphIndexFreshness: 'fresh' | 'stale' | 'missing'; graphIndexDetail: string | null; diff --git a/packages/extension/tests/extension/graphView/editorPanel.test.ts b/packages/extension/tests/extension/graphView/editorPanel.test.ts index 5fd16dcb34..ca5f7594bf 100644 --- a/packages/extension/tests/extension/graphView/editorPanel.test.ts +++ b/packages/extension/tests/extension/graphView/editorPanel.test.ts @@ -5,9 +5,15 @@ import { openGraphViewInEditor } from '../../../src/extension/graphView/editorPa describe('graph view editor panel helper', () => { it('opens a panel, sets its icon/html, and unregisters it on dispose', () => { let disposeHandler: (() => void) | undefined; + let viewStateHandler: Parameters[0] | undefined; const panel = { - webview: { html: '', options: {} }, + visible: true, + webview: { html: '', options: {}, postMessage: vi.fn() }, iconPath: undefined, + onDidChangeViewState: vi.fn((handler: Parameters[0]) => { + viewStateHandler = handler; + return { dispose: () => {} }; + }), onDidDispose: vi.fn((handler: () => void) => { disposeHandler = handler; return { dispose: () => {} }; @@ -28,6 +34,8 @@ describe('graph view editor panel helper', () => { registerPanel, unregisterPanel, }); + panel.visible = false; + viewStateHandler?.({ webviewPanel: panel as unknown as vscode.WebviewPanel }); disposeHandler?.(); expect(createPanel).toHaveBeenCalledWith( @@ -42,6 +50,14 @@ describe('graph view editor panel helper', () => { ); expect(registerPanel).toHaveBeenCalledWith(panel); expect(panel.webview.html).toBe('
'); + expect(panel.webview.postMessage).toHaveBeenNthCalledWith(1, { + type: 'GRAPH_VIEW_VISIBILITY_UPDATED', + payload: { visible: true }, + }); + expect(panel.webview.postMessage).toHaveBeenNthCalledWith(2, { + type: 'GRAPH_VIEW_VISIBILITY_UPDATED', + payload: { visible: false }, + }); expect(panel.iconPath).toEqual({ dark: vscode.Uri.file('/extension/assets/icon-dark.svg'), light: vscode.Uri.file('/extension/assets/icon-light.svg'), diff --git a/packages/extension/tests/extension/graphView/webview/resolve.test.ts b/packages/extension/tests/extension/graphView/webview/resolve.test.ts index 9022cb2350..722ba54a8e 100644 --- a/packages/extension/tests/extension/graphView/webview/resolve.test.ts +++ b/packages/extension/tests/extension/graphView/webview/resolve.test.ts @@ -11,6 +11,7 @@ describe('graphView/webview/resolve', () => { webview: { options: {}, html: '', + postMessage: vi.fn(), }, onDidChangeVisibility: vi.fn((handler: () => void) => { visibilityHandler = handler; @@ -33,6 +34,10 @@ describe('graphView/webview/resolve', () => { expect(setWebviewMessageListener).toHaveBeenCalledWith(webviewView.webview); expect(webviewView.webview.html).toBe('
'); expect(executeCommand).toHaveBeenCalledWith('setContext', 'codegraphy.viewVisible', false); + expect(webviewView.webview.postMessage).toHaveBeenCalledWith({ + type: 'GRAPH_VIEW_VISIBILITY_UPDATED', + payload: { visible: false }, + }); expect(visibilityHandler).toBeTypeOf('function'); }); @@ -44,6 +49,7 @@ describe('graphView/webview/resolve', () => { webview: { options: {}, html: '', + postMessage: vi.fn(), }, onDidChangeVisibility: vi.fn((handler: () => void) => { visibilityHandler = handler; @@ -62,6 +68,7 @@ describe('graphView/webview/resolve', () => { expect(executeCommand).toHaveBeenCalledTimes(2); expect(executeCommand).toHaveBeenLastCalledWith('setContext', 'codegraphy.viewVisible', true); + expect(webviewView.webview.postMessage).toHaveBeenCalledTimes(2); }); it('updates visibility context while the view stays hidden', () => { @@ -72,6 +79,7 @@ describe('graphView/webview/resolve', () => { webview: { options: {}, html: '', + postMessage: vi.fn(), }, onDidChangeVisibility: vi.fn((handler: () => void) => { visibilityHandler = handler; @@ -90,5 +98,9 @@ describe('graphView/webview/resolve', () => { expect(executeCommand).toHaveBeenCalledTimes(2); expect(executeCommand).toHaveBeenLastCalledWith('setContext', 'codegraphy.viewVisible', false); + expect(webviewView.webview.postMessage).toHaveBeenLastCalledWith({ + type: 'GRAPH_VIEW_VISIBILITY_UPDATED', + payload: { visible: false }, + }); }); }); diff --git a/packages/extension/tests/extension/graphViewProvider.webviewApi.test.ts b/packages/extension/tests/extension/graphViewProvider.webviewApi.test.ts index e72c8c79f8..ffb881abff 100644 --- a/packages/extension/tests/extension/graphViewProvider.webviewApi.test.ts +++ b/packages/extension/tests/extension/graphViewProvider.webviewApi.test.ts @@ -18,10 +18,13 @@ describe('GraphViewProvider public API', () => { const createWebviewPanelMock = vi.fn(() => ({ webview: { html: '', + postMessage: vi.fn(), onDidReceiveMessage: vi.fn(() => ({ dispose: () => {} })), asWebviewUri: vi.fn((uri: vscode.Uri) => uri), cspSource: 'test-csp', }, + visible: true, + onDidChangeViewState: vi.fn(() => ({ dispose: () => {} })), onDidDispose: vi.fn(() => ({ dispose: () => {} })), reveal: vi.fn(), dispose: vi.fn(), diff --git a/packages/extension/tests/playwright-vscode/tooltip-frame-lifecycle.spec.ts b/packages/extension/tests/playwright-vscode/tooltip-frame-lifecycle.spec.ts new file mode 100644 index 0000000000..33323f1a38 --- /dev/null +++ b/packages/extension/tests/playwright-vscode/tooltip-frame-lifecycle.spec.ts @@ -0,0 +1,216 @@ +import { expect, test, type Frame } from '@playwright/test'; +import fs from 'node:fs'; +import { + clickFitToScreenIfAvailable, + graphNode, +} from '../acceptance/graphView/canvas'; +import { + launchVSCodeWithWorkspace, + openGraphView, + waitForGraphFrame, +} from '../acceptance/graphView/vscode'; +import { + copyExampleTypescriptWorkspace, + createWorkspaceTempRoot, +} from '../acceptance/graphView/workspace'; + +const captureProof = process.env.CODEGRAPHY_CAPTURE_TOOLTIP_PROOF === '1'; +const explorerShortcut = process.platform === 'darwin' ? 'Meta+Shift+E' : 'Control+Shift+E'; + +test.use({ video: captureProof ? 'on' : 'retain-on-failure' }); + +test('Hover performance: stationary and hidden Node tooltips stop graph frame work in an Extension Development Host', async ({}, testInfo) => { + const workspaceTempRoot = createWorkspaceTempRoot(); + const workspacePath = copyExampleTypescriptWorkspace(workspaceTempRoot); + const vscode = await launchVSCodeWithWorkspace(workspacePath); + + try { + await openGraphView(vscode.page); + const frame = await waitForGraphFrame(vscode.page); + await indexWorkspace(frame); + await clickFitToScreenIfAvailable(frame); + await expect.poll(() => hasPositionedNode(frame, 'src/index.ts'), { + timeout: 10_000, + }).toBe(true); + await expect.poll(() => readGraphFps(frame)).toBeNull(); + await installFrameProbe(frame); + + await graphNode(frame, 'src/index.ts').dispatchEvent('mouseover', { bubbles: true }); + await expect(frame.getByText('src/index.ts', { exact: true }).first()).toBeVisible({ + timeout: 10_000, + }); + await waitForFrameProbeIdle(frame); + await resetFrameProbe(frame); + const stationaryFrameRequests = await observeFrameRequests(frame, 1_500); + expect(stationaryFrameRequests).toBe(0); + expect(await readGraphFps(frame)).toBeNull(); + await showFrameEvidence(frame, { + stationaryFrameRequests, + }); + + const screenshotPath = testInfo.outputPath('stationary-tooltip-zero-frames.png'); + await frame.locator('body').screenshot({ path: screenshotPath }); + await testInfo.attach('stationary tooltip zero frames', { + contentType: 'image/png', + path: screenshotPath, + }); + + await vscode.page.keyboard.press(explorerShortcut); + await expect(frameProbe(frame)).toHaveAttribute('data-graph-view-visible', 'false', { + timeout: 10_000, + }); + expect(await readDocumentVisibility(frame)).toBe('visible'); + await expect(frame.getByText('src/index.ts', { exact: true })).toHaveCount(0); + await waitForFrameProbeIdle(frame); + await resetFrameProbe(frame); + const hiddenFrameRequests = await observeFrameRequests(frame, 1_500); + expect(hiddenFrameRequests).toBe(0); + expect(await readGraphFps(frame)).toBeNull(); + + await openGraphView(vscode.page); + await expect(frameProbe(frame)).toHaveAttribute('data-graph-view-visible', 'true', { + timeout: 10_000, + }); + await graphNode(frame, 'src/index.ts').dispatchEvent('mouseover', { bubbles: true }); + await expect(frame.getByText('src/index.ts', { exact: true }).first()).toBeVisible(); + await showFrameEvidence(frame, { hiddenFrameRequests, stationaryFrameRequests }); + const lifecycleScreenshotPath = testInfo.outputPath('tooltip-frame-lifecycle.png'); + await frame.locator('body').screenshot({ path: lifecycleScreenshotPath }); + await testInfo.attach('tooltip frame lifecycle', { + contentType: 'image/png', + path: lifecycleScreenshotPath, + }); + } finally { + await vscode.app.close().catch(() => {}); + fs.rmSync(vscode.tempRoot, { recursive: true, force: true }); + fs.rmSync(workspaceTempRoot, { recursive: true, force: true }); + } +}); + +async function indexWorkspace(frame: Frame): Promise { + const indexButton = frame.getByRole('button', { name: 'Index Workspace' }); + if (await indexButton.count() === 0 || !(await indexButton.first().isVisible().catch(() => false))) { + return; + } + await indexButton.click(); + await expect(frame.getByRole('progressbar', { name: 'Indexing progress' })) + .toBeHidden({ timeout: 30_000 }); +} + +async function hasPositionedNode(frame: Frame, nodeId: string): Promise { + return frame.evaluate((targetNodeId) => { + return window.__CODEGRAPHY_GRAPH_DEBUG__?.getSnapshot().nodes.some( + node => node.id === targetNodeId && node.positionFinite, + ) ?? false; + }, nodeId); +} + +async function installFrameProbe(frame: Frame): Promise { + await frame.evaluate(() => { + const output = document.createElement('output'); + output.dataset.codegraphyTooltipFrameProbe = 'true'; + output.dataset.frameRequests = '0'; + output.dataset.graphViewVisible = 'true'; + output.hidden = true; + document.body.append(output); + window.addEventListener('message', (event: MessageEvent) => { + const message = event.data; + if (!message || typeof message !== 'object') return; + if (!('type' in message) || message.type !== 'GRAPH_VIEW_VISIBILITY_UPDATED') return; + if (!('payload' in message) || !message.payload || typeof message.payload !== 'object') return; + if (!('visible' in message.payload) || typeof message.payload.visible !== 'boolean') return; + output.dataset.graphViewVisible = String(message.payload.visible); + }); + const requestFrame = window.requestAnimationFrame.bind(window); + window.requestAnimationFrame = callback => { + output.dataset.frameRequests = String( + Number(output.dataset.frameRequests ?? '0') + 1, + ); + return requestFrame(callback); + }; + }); +} + +function frameProbe(frame: Frame) { + return frame.locator('[data-codegraphy-tooltip-frame-probe="true"]'); +} + +async function resetFrameProbe(frame: Frame): Promise { + await frameProbe(frame).evaluate((element) => { + element.dataset.frameRequests = '0'; + }); +} + +async function readFrameProbe(frame: Frame): Promise { + return frameProbe(frame).evaluate(element => Number(element.dataset.frameRequests)); +} + +async function waitForFrameProbeIdle(frame: Frame): Promise { + let previousFrameRequests = await readFrameProbe(frame); + let lastChangeAt = Date.now(); + await expect.poll(async () => { + const currentFrameRequests = await readFrameProbe(frame); + if (currentFrameRequests !== previousFrameRequests) { + previousFrameRequests = currentFrameRequests; + lastChangeAt = Date.now(); + } + return Date.now() - lastChangeAt; + }, { intervals: [100], timeout: 5_000 }).toBeGreaterThanOrEqual(500); +} + +async function observeFrameRequests(frame: Frame, durationMs: number): Promise { + const startedAt = Date.now(); + let maximumFrameRequests = 0; + await expect.poll(async () => { + maximumFrameRequests = Math.max(maximumFrameRequests, await readFrameProbe(frame)); + return { + complete: Date.now() - startedAt >= durationMs, + maximumFrameRequests, + }; + }, { + intervals: [100], + timeout: durationMs + 3_000, + }).toEqual({ complete: true, maximumFrameRequests: 0 }); + return maximumFrameRequests; +} + +async function readGraphFps(frame: Frame): Promise { + return frame.evaluate(() => window.__CODEGRAPHY_GRAPH_DEBUG__?.getSnapshot().fps ?? null); +} + +async function readDocumentVisibility(frame: Frame): Promise { + return frame.evaluate(() => document.visibilityState); +} + +async function showFrameEvidence(frame: Frame, evidence: { + hiddenFrameRequests?: number; + stationaryFrameRequests: number; +}): Promise { + await frame.evaluate((currentEvidence) => { + const output = document.querySelector('[data-codegraphy-tooltip-frame-probe="true"]'); + if (!(output instanceof HTMLOutputElement)) return; + output.hidden = false; + output.style.cssText = [ + 'position: fixed', + 'right: 16px', + 'bottom: 16px', + 'z-index: 9999', + 'padding: 10px 12px', + 'border: 1px solid #22c55e', + 'border-radius: 8px', + 'background: #052e16', + 'color: #dcfce7', + 'font: 12px/1.5 ui-monospace, monospace', + 'white-space: pre-line', + 'box-shadow: 0 8px 24px rgba(0,0,0,.35)', + ].join(';'); + output.textContent = [ + 'Stationary Node tooltip', + `1.5 s webview RAF requests: ${currentEvidence.stationaryFrameRequests}`, + 'Physics/render FPS: idle', + ...(currentEvidence.hiddenFrameRequests === undefined + ? [] + : [`Hidden Graph View webview RAF requests: ${currentEvidence.hiddenFrameRequests}`]), + ].join('\n'); + }, evidence); +} diff --git a/packages/extension/tests/playwrightVscodeConfig.test.ts b/packages/extension/tests/playwrightVscodeConfig.test.ts index 1da1c34980..b9fc91e564 100644 --- a/packages/extension/tests/playwrightVscodeConfig.test.ts +++ b/packages/extension/tests/playwrightVscodeConfig.test.ts @@ -44,6 +44,15 @@ describe('VS Code Playwright config', () => { expect(grepInvert?.test('Graph Scope Edge Types - C++ Include edge type shows C++ include relationships')).toBe(false); }); + it('assigns the tooltip frame lifecycle regression to one CI Playwright slice', () => { + const title = 'Hover performance: stationary and hidden Node tooltips stop graph frame work in an Extension Development Host'; + const labels = readPlaywrightMatrixSlices() + .filter(slice => isTitleIncludedInSlice(title, slice)) + .map(slice => slice.label); + + expect(labels).toEqual(['Graph interactions - hover and selection']); + }); + it('assigns every generated acceptance scenario to exactly one CI Playwright slice', () => { const matrix = readPlaywrightMatrixSlices(); const titles = readGeneratedAcceptanceTitles(); diff --git a/packages/extension/tests/webview/Graph.wiring.fixture.ts b/packages/extension/tests/webview/Graph.wiring.fixture.ts index 5f7ada7a97..aba44a8f8b 100644 --- a/packages/extension/tests/webview/Graph.wiring.fixture.ts +++ b/packages/extension/tests/webview/Graph.wiring.fixture.ts @@ -94,6 +94,7 @@ export function createInteractionRuntime() { pluginActions: [], pluginSections: [], }, + updateTooltipAnchor: vi.fn(), }; } diff --git a/packages/extension/tests/webview/graph/contextMenuOpening/runtime.test.ts b/packages/extension/tests/webview/graph/contextMenuOpening/runtime.test.ts index 085dd3b307..6c8c0a2c63 100644 --- a/packages/extension/tests/webview/graph/contextMenuOpening/runtime.test.ts +++ b/packages/extension/tests/webview/graph/contextMenuOpening/runtime.test.ts @@ -75,7 +75,7 @@ function createOpeningOptions(overrides: Record = {}) { }, setContextSelection: vi.fn(), setTooltipData: vi.fn(), - stopTooltipTracking: vi.fn(), + clearTooltipAnchor: vi.fn(), tooltipTimeoutRef: { current: null }, ...overrides, }; @@ -109,7 +109,7 @@ describe('graph/contextMenuOpening/runtime', () => { dependencies.postMessage({ type: 'REFRESH_GRAPH' }); dependencies.setContextSelection({ kind: 'background', targets: [] }); dependencies.setTooltipData({ visible: true }); - dependencies.stopTooltipTracking(); + dependencies.clearTooltipAnchor(); expect(options.fileInfoCacheRef.current.has('src/stale.ts')).toBe(false); expect(options.interactionHandlers.fitView).toHaveBeenCalledTimes(1); @@ -120,7 +120,7 @@ describe('graph/contextMenuOpening/runtime', () => { expect(openingHarness.postMessage).toHaveBeenCalledWith({ type: 'REFRESH_GRAPH' }); expect(options.setContextSelection).toHaveBeenCalledWith({ kind: 'background', targets: [] }); expect(options.setTooltipData).toHaveBeenCalledWith({ visible: true }); - expect(options.stopTooltipTracking).toHaveBeenCalledTimes(1); + expect(options.clearTooltipAnchor).toHaveBeenCalledTimes(1); }); it('translates menu opening handlers and menu actions', () => { diff --git a/packages/extension/tests/webview/graph/contextMenuRuntime/controller.postspluginmenuactionsthroughtoappliescontexteffectsdirectlythrough.test.ts b/packages/extension/tests/webview/graph/contextMenuRuntime/controller.postspluginmenuactionsthroughtoappliescontexteffectsdirectlythrough.test.ts index ab554c4761..dbf22fd81d 100644 --- a/packages/extension/tests/webview/graph/contextMenuRuntime/controller.postspluginmenuactionsthroughtoappliescontexteffectsdirectlythrough.test.ts +++ b/packages/extension/tests/webview/graph/contextMenuRuntime/controller.postspluginmenuactionsthroughtoappliescontexteffectsdirectlythrough.test.ts @@ -42,7 +42,7 @@ function createDependencies( setTooltipData: vi.fn((updater) => { tooltipState = updater(tooltipState); }), - stopTooltipTracking: vi.fn(), + clearTooltipAnchor: vi.fn(), ...overrides, }; diff --git a/packages/extension/tests/webview/graph/contextMenuRuntime/controller.skipsfallbackschedulingwhenthetoappliesbuiltinmenuactions.test.ts b/packages/extension/tests/webview/graph/contextMenuRuntime/controller.skipsfallbackschedulingwhenthetoappliesbuiltinmenuactions.test.ts index e5c55ddc9a..b298021d7a 100644 --- a/packages/extension/tests/webview/graph/contextMenuRuntime/controller.skipsfallbackschedulingwhenthetoappliesbuiltinmenuactions.test.ts +++ b/packages/extension/tests/webview/graph/contextMenuRuntime/controller.skipsfallbackschedulingwhenthetoappliesbuiltinmenuactions.test.ts @@ -47,7 +47,7 @@ function createDependencies( setTooltipData: vi.fn((updater) => { tooltipState = updater(tooltipState); }), - stopTooltipTracking: vi.fn(), + clearTooltipAnchor: vi.fn(), ...overrides, }; @@ -130,7 +130,7 @@ describe('graph/contextMenuRuntime', () => { }); expect(dependencies.tooltipTimeoutRef.current).toBeNull(); expect(dependencies.hoveredNodeRef.current).toBeNull(); - expect(dependencies.stopTooltipTracking).toHaveBeenCalledOnce(); + expect(dependencies.clearTooltipAnchor).toHaveBeenCalledOnce(); expect(getTooltipState()).toMatchObject({ visible: false, pluginSections: [], @@ -151,7 +151,7 @@ describe('graph/contextMenuRuntime', () => { expect(dependencies.setContextSelection).not.toHaveBeenCalled(); expect(dependencies.tooltipTimeoutRef.current).toBeNull(); expect(dependencies.hoveredNodeRef.current).toBeNull(); - expect(dependencies.stopTooltipTracking).toHaveBeenCalledOnce(); + expect(dependencies.clearTooltipAnchor).toHaveBeenCalledOnce(); expect(getTooltipState()).toMatchObject({ visible: false, pluginSections: [], diff --git a/packages/extension/tests/webview/graph/contextMenuRuntime/controller.test.ts b/packages/extension/tests/webview/graph/contextMenuRuntime/controller.test.ts index d71a62d709..74e8d8ab67 100644 --- a/packages/extension/tests/webview/graph/contextMenuRuntime/controller.test.ts +++ b/packages/extension/tests/webview/graph/contextMenuRuntime/controller.test.ts @@ -42,7 +42,7 @@ function createDependencies( setTooltipData: vi.fn((updater) => { tooltipState = updater(tooltipState); }), - stopTooltipTracking: vi.fn(), + clearTooltipAnchor: vi.fn(), ...overrides, }; diff --git a/packages/extension/tests/webview/graph/contextMenuRuntime/fallback/scheduler.test.ts b/packages/extension/tests/webview/graph/contextMenuRuntime/fallback/scheduler.test.ts index 10846d5814..b5a548c679 100644 --- a/packages/extension/tests/webview/graph/contextMenuRuntime/fallback/scheduler.test.ts +++ b/packages/extension/tests/webview/graph/contextMenuRuntime/fallback/scheduler.test.ts @@ -28,7 +28,7 @@ function createDependencies( postMessage: vi.fn(), setContextSelection: vi.fn(), setTooltipData: vi.fn(), - stopTooltipTracking: vi.fn(), + clearTooltipAnchor: vi.fn(), ...overrides, }; diff --git a/packages/extension/tests/webview/graph/contextMenuRuntime/pointer.test.ts b/packages/extension/tests/webview/graph/contextMenuRuntime/pointer.test.ts index 942763273f..9e10ab5cbc 100644 --- a/packages/extension/tests/webview/graph/contextMenuRuntime/pointer.test.ts +++ b/packages/extension/tests/webview/graph/contextMenuRuntime/pointer.test.ts @@ -28,7 +28,7 @@ function createDependencies( postMessage: vi.fn(), setContextSelection: vi.fn(), setTooltipData: vi.fn(), - stopTooltipTracking: vi.fn(), + clearTooltipAnchor: vi.fn(), ...overrides, }; diff --git a/packages/extension/tests/webview/graph/contextMenuRuntime/tooltip.test.ts b/packages/extension/tests/webview/graph/contextMenuRuntime/tooltip.test.ts index b593a7be90..4177f97625 100644 --- a/packages/extension/tests/webview/graph/contextMenuRuntime/tooltip.test.ts +++ b/packages/extension/tests/webview/graph/contextMenuRuntime/tooltip.test.ts @@ -37,7 +37,7 @@ function createDependencies( setTooltipData: vi.fn((updater) => { tooltipState = updater(tooltipState); }), - stopTooltipTracking: vi.fn(), + clearTooltipAnchor: vi.fn(), ...overrides, }; diff --git a/packages/extension/tests/webview/graph/rendering/surface/owned2d/frame/runtime/loop.test.ts b/packages/extension/tests/webview/graph/rendering/surface/owned2d/frame/runtime/loop.test.ts index 815807e577..b9d646a150 100644 --- a/packages/extension/tests/webview/graph/rendering/surface/owned2d/frame/runtime/loop.test.ts +++ b/packages/extension/tests/webview/graph/rendering/surface/owned2d/frame/runtime/loop.test.ts @@ -62,7 +62,7 @@ describe('owned graph frame loop', () => { frameRequestedRef: { current: false }, gpuRendererRef: { current: null }, layoutRef: { current: null }, - propsRef: { current: { showFps: false } }, + propsRef: { current: { graphViewVisible: true, showFps: false } }, publishPerformance, rendererOperationalRef: { current: false }, requestFrameRef: { current: () => {} }, @@ -111,6 +111,7 @@ describe('owned graph frame loop', () => { frameRequestedRef: { current: false }, gpuRendererRef: { current: { canRender: () => false } }, layoutRef: { current: null }, + propsRef: { current: { graphViewVisible: true } }, rendererOperationalRef: { current: false }, requestFrameRef: { current: () => {} }, } as unknown as OwnedGraphFrameLoopRuntime; @@ -137,4 +138,46 @@ describe('owned graph frame loop', () => { runtime.requestFrameRef.current(); expect(requestAnimationFrame).toHaveBeenCalledOnce(); }); + + it('cancels a running retained view and performs zero frames while host-hidden', () => { + vi.mocked(renderOwnedGraphFrame).mockClear(); + Object.defineProperty(document, 'visibilityState', { + configurable: true, + value: 'visible', + }); + const requestAnimationFrame = vi.fn(() => 23); + vi.stubGlobal('requestAnimationFrame', requestAnimationFrame); + vi.stubGlobal('cancelAnimationFrame', vi.fn()); + vi.stubGlobal('ResizeObserver', ResizeObserverHarness); + const runtime = { + animationFrameRef: { current: null }, + cameraRef: { current: { centerX: 0, centerY: 0, zoom: 1 } }, + engineStopNotifiedRef: { current: false }, + frameRequestedRef: { current: false }, + gpuRendererRef: { current: null }, + hoveredNodeRef: { current: { id: 'src/app.ts' } }, + layoutRef: { current: {} }, + propsRef: { current: { graphViewVisible: true } }, + rendererOperationalRef: { current: true }, + requestFrameRef: { current: () => {} }, + } as unknown as OwnedGraphFrameLoopRuntime; + + const loop = startOwnedGraphFrameLoop( + runtime, + document.createElement('canvas'), + { current: undefined }, + ); + expect(requestAnimationFrame).toHaveBeenCalledOnce(); + loop.setHostVisible(false); + runtime.requestFrameRef.current(); + + expect(runtime.frameRequestedRef.current).toBe(true); + expect(requestAnimationFrame).toHaveBeenCalledOnce(); + expect(renderOwnedGraphFrame).not.toHaveBeenCalled(); + + loop.setHostVisible(true); + expect(requestAnimationFrame).toHaveBeenCalledTimes(2); + + loop.dispose(); + }); }); diff --git a/packages/extension/tests/webview/graph/rendering/surface/owned2d/frame/runtime/loop.visibility.integration.test.ts b/packages/extension/tests/webview/graph/rendering/surface/owned2d/frame/runtime/loop.visibility.integration.test.ts new file mode 100644 index 0000000000..ae8b91831a --- /dev/null +++ b/packages/extension/tests/webview/graph/rendering/surface/owned2d/frame/runtime/loop.visibility.integration.test.ts @@ -0,0 +1,74 @@ +import type { WebGpuGraphRenderer } from '@codegraphy-dev/graph-renderer'; +import { afterEach, describe, expect, it, vi } from 'vitest'; +import { + startOwnedGraphFrameLoop, + type OwnedGraphFrameLoopRuntime, +} from '../../../../../../../../src/webview/components/graph/rendering/surface/owned2d/frame/runtime/loop'; +import { canvasFixture, runtimeFixture } from './fixture'; + +class ResizeObserverHarness { + disconnect(): void {} + observe(): void {} + unobserve(): void {} +} + +afterEach(() => vi.unstubAllGlobals()); + +describe('owned graph retained-view visibility', () => { + it('stops renderer submissions and physics after a running host view becomes hidden', () => { + const callbacks = new Map(); + let nextFrameId = 1; + vi.stubGlobal('requestAnimationFrame', vi.fn((callback: FrameRequestCallback) => { + const id = nextFrameId; + nextFrameId += 1; + callbacks.set(id, callback); + return id; + })); + vi.stubGlobal('cancelAnimationFrame', vi.fn((id: number) => callbacks.delete(id))); + vi.stubGlobal('ResizeObserver', ResizeObserverHarness); + + const renderer = { + canRender: () => true, + render: vi.fn(() => 1), + } as unknown as WebGpuGraphRenderer; + const { layout, runtime } = runtimeFixture(renderer); + const physicsTick = vi.spyOn(layout.engine, 'tick'); + const loopRuntime = { + ...runtime, + animationFrameRef: { current: null }, + fpsRef: { current: null }, + frameRequestedRef: { current: false }, + performanceMonitorRef: { + current: { + completeFrame: vi.fn(), + discardFrame: vi.fn(), + reset: vi.fn(), + sample: vi.fn(() => ({ status: 'idle' as const })), + setIdle: vi.fn(() => ({ status: 'idle' as const })), + stageFrame: vi.fn(), + }, + }, + publishPerformance: vi.fn(), + } as unknown as OwnedGraphFrameLoopRuntime; + const canvas = canvasFixture(); + const loop = startOwnedGraphFrameLoop(loopRuntime, canvas, { current: undefined }); + + const firstFrame = callbacks.values().next().value; + callbacks.clear(); + firstFrame?.(100); + expect(renderer.render).toHaveBeenCalledOnce(); + expect(physicsTick).toHaveBeenCalled(); + expect(callbacks.size).toBe(1); + + loop.setHostVisible(false); + const submissionsWhenHidden = vi.mocked(renderer.render).mock.calls.length; + const physicsTicksWhenHidden = physicsTick.mock.calls.length; + loopRuntime.requestFrameRef.current(); + + expect(callbacks.size).toBe(0); + expect(renderer.render).toHaveBeenCalledTimes(submissionsWhenHidden); + expect(physicsTick).toHaveBeenCalledTimes(physicsTicksWhenHidden); + + loop.dispose(); + }); +}); diff --git a/packages/extension/tests/webview/graph/rendering/surface/owned2d/view/surface/fixture.ts b/packages/extension/tests/webview/graph/rendering/surface/owned2d/view/surface/fixture.ts index 80a382e6ca..674073e4c4 100644 --- a/packages/extension/tests/webview/graph/rendering/surface/owned2d/view/surface/fixture.ts +++ b/packages/extension/tests/webview/graph/rendering/surface/owned2d/view/surface/fixture.ts @@ -7,6 +7,7 @@ export function createDefaultSurfaceProps(): Surface2dProps { backgroundColor: '#1e1e1e', directionMode: 'arrows', fg2dRef: { current: undefined }, + graphViewVisible: true, getBaseLinkColor: vi.fn(() => '#888888'), getBaseLinkOpacity: vi.fn(() => 0.3), getBaseLinkWidth: vi.fn(() => 1), diff --git a/packages/extension/tests/webview/graph/runtime/tooltip/hover.test.ts b/packages/extension/tests/webview/graph/runtime/tooltip/hover.test.ts index dc7e28128d..eac2f44e1d 100644 --- a/packages/extension/tests/webview/graph/runtime/tooltip/hover.test.ts +++ b/packages/extension/tests/webview/graph/runtime/tooltip/hover.test.ts @@ -24,8 +24,8 @@ describe('handleTooltipNodeHover', () => { pluginHost: undefined, postMessage: vi.fn(), setTooltipData: vi.fn(), - startTracking: vi.fn(), - stopTracking: vi.fn(), + initializeAnchorSnapshot: vi.fn(), + clearAnchorSnapshot: vi.fn(), tooltipTimeoutRef: { current: existingTimeout }, }); @@ -46,8 +46,8 @@ describe('handleTooltipNodeHover', () => { pluginHost: undefined, postMessage: vi.fn(), setTooltipData: vi.fn(), - startTracking: vi.fn(), - stopTracking: vi.fn(), + initializeAnchorSnapshot: vi.fn(), + clearAnchorSnapshot: vi.fn(), tooltipTimeoutRef: { current: null }, }); @@ -60,7 +60,7 @@ describe('handleTooltipNodeHover', () => { setGraphCursor: vi.fn(), }; const setTooltipData = vi.fn(); - const stopTracking = vi.fn(); + const clearAnchorSnapshot = vi.fn(); handleTooltipNodeHover(null, { dataRef: { current: { edges: [], nodes: [] } as IGraphData }, @@ -71,14 +71,14 @@ describe('handleTooltipNodeHover', () => { pluginHost: undefined, postMessage: vi.fn(), setTooltipData, - startTracking: vi.fn(), - stopTracking, + initializeAnchorSnapshot: vi.fn(), + clearAnchorSnapshot, tooltipTimeoutRef: { current: null }, }); expect(interactionHandlers.setGraphCursor).toHaveBeenCalledWith('default'); expect(hoveredNodeRef.current).toBeNull(); - expect(stopTracking).toHaveBeenCalledOnce(); + expect(clearAnchorSnapshot).toHaveBeenCalledOnce(); expect(setTooltipData).toHaveBeenCalledOnce(); }); @@ -88,7 +88,7 @@ describe('handleTooltipNodeHover', () => { }; const postMessage = vi.fn(); const setTooltipData = vi.fn(); - const startTracking = vi.fn(); + const initializeAnchorSnapshot = vi.fn(); const hoveredNodeRef = { current: null as FGNode | null }; const node = { color: '#123456', id: 'src/App.ts', label: 'App' } as FGNode; @@ -110,8 +110,8 @@ describe('handleTooltipNodeHover', () => { } as unknown as NonNullable[1]['pluginHost']>, postMessage, setTooltipData, - startTracking, - stopTracking: vi.fn(), + initializeAnchorSnapshot, + clearAnchorSnapshot: vi.fn(), tooltipTimeoutRef: { current: null }, }); @@ -123,7 +123,7 @@ describe('handleTooltipNodeHover', () => { payload: { path: 'src/App.ts' }, type: 'GET_FILE_INFO', }); - expect(startTracking).toHaveBeenCalledOnce(); + expect(initializeAnchorSnapshot).toHaveBeenCalledOnce(); expect(hoveredNodeRef.current).toBe(node); }); @@ -131,7 +131,7 @@ describe('handleTooltipNodeHover', () => { const cachedInfo = { path: 'src/App.ts' } as IFileInfo; const postMessage = vi.fn(); const setTooltipData = vi.fn(); - const startTracking = vi.fn(); + const initializeAnchorSnapshot = vi.fn(); const node = { color: '#123456', id: 'src/App.ts', label: 'App' } as FGNode; handleTooltipNodeHover(node, { @@ -150,8 +150,8 @@ describe('handleTooltipNodeHover', () => { pluginHost: undefined, postMessage, setTooltipData, - startTracking, - stopTracking: vi.fn(), + initializeAnchorSnapshot, + clearAnchorSnapshot: vi.fn(), tooltipTimeoutRef: { current: null }, }); @@ -168,7 +168,7 @@ describe('handleTooltipNodeHover', () => { visible: true, }); expect(postMessage).not.toHaveBeenCalled(); - expect(startTracking).toHaveBeenCalledOnce(); + expect(initializeAnchorSnapshot).toHaveBeenCalledOnce(); }); it('does not request file info for synthetic package nodes', () => { @@ -201,8 +201,8 @@ describe('handleTooltipNodeHover', () => { pluginHost: undefined, postMessage, setTooltipData: vi.fn(), - startTracking: vi.fn(), - stopTracking: vi.fn(), + initializeAnchorSnapshot: vi.fn(), + clearAnchorSnapshot: vi.fn(), tooltipTimeoutRef: { current: null }, }); @@ -236,8 +236,8 @@ describe('handleTooltipNodeHover', () => { pluginHost: undefined, postMessage, setTooltipData, - startTracking: vi.fn(), - stopTracking: vi.fn(), + initializeAnchorSnapshot: vi.fn(), + clearAnchorSnapshot: vi.fn(), tooltipTimeoutRef: { current: null }, }); @@ -275,8 +275,8 @@ describe('handleTooltipNodeHover', () => { pluginHost: undefined, postMessage, setTooltipData, - startTracking: vi.fn(), - stopTracking: vi.fn(), + initializeAnchorSnapshot: vi.fn(), + clearAnchorSnapshot: vi.fn(), tooltipTimeoutRef: { current: null }, }); @@ -289,7 +289,7 @@ describe('handleTooltipNodeHover', () => { it('clears the previous hover timeout when a second node is hovered before the first delay elapses', () => { const clearTimeoutSpy = vi.spyOn(globalThis, 'clearTimeout'); const setTooltipData = vi.fn(); - const startTracking = vi.fn(); + const initializeAnchorSnapshot = vi.fn(); const firstNode = { color: '#123456', id: 'src/first.ts', label: 'First' } as FGNode; const secondNode = { color: '#654321', id: 'src/second.ts', label: 'Second' } as FGNode; const tooltipTimeoutRef = { current: null as ReturnType | null }; @@ -305,8 +305,8 @@ describe('handleTooltipNodeHover', () => { pluginHost: undefined, postMessage: vi.fn(), setTooltipData, - startTracking, - stopTracking: vi.fn(), + initializeAnchorSnapshot, + clearAnchorSnapshot: vi.fn(), tooltipTimeoutRef, }); @@ -321,8 +321,8 @@ describe('handleTooltipNodeHover', () => { pluginHost: undefined, postMessage: vi.fn(), setTooltipData, - startTracking, - stopTracking: vi.fn(), + initializeAnchorSnapshot, + clearAnchorSnapshot: vi.fn(), tooltipTimeoutRef, }); @@ -331,7 +331,7 @@ describe('handleTooltipNodeHover', () => { vi.advanceTimersByTime(500); expect(setTooltipData).toHaveBeenCalledOnce(); - expect(startTracking).toHaveBeenCalledOnce(); + expect(initializeAnchorSnapshot).toHaveBeenCalledOnce(); expect(tooltipTimeoutRef.current).not.toBeNull(); }); }); diff --git a/packages/extension/tests/webview/graph/runtime/tooltip/tracking.test.ts b/packages/extension/tests/webview/graph/runtime/tooltip/tracking.test.ts index f46dcc0ffa..ac881e987b 100644 --- a/packages/extension/tests/webview/graph/runtime/tooltip/tracking.test.ts +++ b/packages/extension/tests/webview/graph/runtime/tooltip/tracking.test.ts @@ -3,40 +3,36 @@ import type { Dispatch, SetStateAction } from 'react'; import type { FGNode } from '../../../../../src/webview/components/graph/model/build'; import type { GraphTooltipState } from '../../../../../src/webview/components/graph/tooltip/model'; import { - startTooltipTracking, - stopTooltipTracking, + clearTooltipAnchorSnapshot, + initializeTooltipAnchorSnapshot, + updateTooltipAnchorSnapshot, } from '../../../../../src/webview/components/graph/runtime/tooltip/tracking'; -describe('tooltipTracking', () => { - it('cancels an in-flight animation frame', () => { - const cancelAnimationFrameSpy = vi.fn(); - vi.stubGlobal('cancelAnimationFrame', cancelAnimationFrameSpy); - const tooltipRafRef = { current: 42 }; +describe('tooltip anchor snapshots', () => { + it('clears the tracked anchor', () => { + const tooltipRectRef = { current: { x: 10, y: 20, radius: 30 } }; - stopTooltipTracking(tooltipRafRef); + clearTooltipAnchorSnapshot(tooltipRectRef); - expect(cancelAnimationFrameSpy).toHaveBeenCalledWith(42); - expect(tooltipRafRef.current).toBeNull(); + expect(tooltipRectRef.current).toBeNull(); }); - it('does not cancel an animation frame when none is active', () => { - const cancelAnimationFrameSpy = vi.fn(); - vi.stubGlobal('cancelAnimationFrame', cancelAnimationFrameSpy); - const tooltipRafRef = { current: null }; + it('initializes the anchor snapshot without owning an animation frame', () => { + const requestAnimationFrameSpy = vi.fn(); + vi.stubGlobal('requestAnimationFrame', requestAnimationFrameSpy); + const tooltipRectRef = { current: null }; - stopTooltipTracking(tooltipRafRef); + initializeTooltipAnchorSnapshot({ + getNodeRect: () => ({ x: 10, y: 20, radius: 30 }), + hoveredNodeRef: { current: { id: 'node' } as FGNode }, + tooltipRectRef, + }); - expect(cancelAnimationFrameSpy).not.toHaveBeenCalled(); - expect(tooltipRafRef.current).toBeNull(); + expect(tooltipRectRef.current).toEqual({ x: 10, y: 20, radius: 30 }); + expect(requestAnimationFrameSpy).not.toHaveBeenCalled(); }); - it('updates the tooltip rect while the tooltip stays visible', () => { - let frameCallback: FrameRequestCallback | undefined; - vi.stubGlobal('requestAnimationFrame', (callback: FrameRequestCallback) => { - frameCallback = callback; - return 7; - }); - + it('updates visible tooltip state only after its anchor changes', () => { const setTooltipData = vi.fn( (update: SetStateAction) => { const nextState = @@ -52,104 +48,59 @@ describe('tooltipTracking', () => { expect(nextState.nodeRect).toEqual({ x: 10, y: 20, radius: 30 }); }, ) as Dispatch>; - const tooltipRafRef = { current: null as number | null }; + const tooltipRectRef = { current: { x: 1, y: 2, radius: 3 } }; - startTooltipTracking({ + updateTooltipAnchorSnapshot({ getNodeRect: () => ({ x: 10, y: 20, radius: 30 }), hoveredNodeRef: { current: { id: 'node' } as FGNode }, setTooltipData, - tooltipRafRef, + tooltipRectRef, }); - frameCallback?.(0); - expect(setTooltipData).toHaveBeenCalledOnce(); - expect(tooltipRafRef.current).toBe(7); + expect(tooltipRectRef.current).toEqual({ x: 10, y: 20, radius: 30 }); }); - it('does not continue tracking after the hovered node disappears', () => { - let frameCallback: FrameRequestCallback | undefined; - const requestAnimationFrameSpy = vi.fn((callback: FrameRequestCallback) => { - frameCallback = callback; - return 11; - }); + it('does not update a stationary tooltip across repeated graph frames', () => { const getNodeRect = vi.fn(); - - vi.stubGlobal('requestAnimationFrame', requestAnimationFrameSpy); - - const tooltipRafRef = { current: null as number | null }; - - startTooltipTracking({ - getNodeRect, - hoveredNodeRef: { current: null }, - setTooltipData: vi.fn(), - tooltipRafRef, - }); - - frameCallback?.(0); - - expect(getNodeRect).not.toHaveBeenCalled(); - expect(requestAnimationFrameSpy).toHaveBeenCalledOnce(); - expect(tooltipRafRef.current).toBe(11); - }); - - it('does not update tooltip state when the node rect is unavailable', () => { - let frameCallback: FrameRequestCallback | undefined; - const requestAnimationFrameSpy = vi.fn((callback: FrameRequestCallback) => { - frameCallback = callback; - return requestAnimationFrameSpy.mock.calls.length + 20; - }); const setTooltipData = vi.fn(); - - vi.stubGlobal('requestAnimationFrame', requestAnimationFrameSpy); - - const tooltipRafRef = { current: null as number | null }; - - startTooltipTracking({ - getNodeRect: () => null, + const rect = { x: 10, y: 20, radius: 30 }; + getNodeRect.mockReturnValue(rect); + const options = { + getNodeRect, hoveredNodeRef: { current: { id: 'node' } as FGNode }, setTooltipData, - tooltipRafRef, - }); + tooltipRectRef: { current: rect }, + }; - frameCallback?.(0); + updateTooltipAnchorSnapshot(options); + updateTooltipAnchorSnapshot(options); + expect(getNodeRect).toHaveBeenCalledTimes(2); expect(setTooltipData).not.toHaveBeenCalled(); - expect(requestAnimationFrameSpy).toHaveBeenCalledTimes(2); - expect(tooltipRafRef.current).toBe(22); }); - it('keeps hidden tooltip state unchanged while tracking continues', () => { - let frameCallback: FrameRequestCallback | undefined; - vi.stubGlobal('requestAnimationFrame', (callback: FrameRequestCallback) => { - frameCallback = callback; - return 15; - }); + it('stops when the hovered node disappears or its rect is unavailable', () => { + const setTooltipData = vi.fn(); + const tooltipRectRef = { current: { x: 10, y: 20, radius: 30 } }; - const hiddenState: GraphTooltipState = { - info: null, - nodeRect: { radius: 1, x: 1, y: 1 }, - path: 'node', - pluginSections: [], - visible: false, - }; - const setTooltipData = vi.fn( - (update: SetStateAction) => { - if (typeof update === 'function') { - expect(update(hiddenState)).toBe(hiddenState); - } - }, - ) as Dispatch>; + updateTooltipAnchorSnapshot({ + getNodeRect: vi.fn(), + hoveredNodeRef: { current: null }, + setTooltipData, + tooltipRectRef, + }); + expect(tooltipRectRef.current).toBeNull(); - startTooltipTracking({ - getNodeRect: () => ({ x: 10, y: 20, radius: 30 }), + tooltipRectRef.current = { x: 10, y: 20, radius: 30 }; + updateTooltipAnchorSnapshot({ + getNodeRect: () => null, hoveredNodeRef: { current: { id: 'node' } as FGNode }, setTooltipData, - tooltipRafRef: { current: null }, + tooltipRectRef, }); - frameCallback?.(0); - - expect(setTooltipData).toHaveBeenCalledOnce(); + expect(tooltipRectRef.current).toBeNull(); + expect(setTooltipData).not.toHaveBeenCalled(); }); }); diff --git a/packages/extension/tests/webview/graph/runtime/use/interactionAdapters.test.tsx b/packages/extension/tests/webview/graph/runtime/use/interactionAdapters.test.tsx index edf663e33c..cfecc5e774 100644 --- a/packages/extension/tests/webview/graph/runtime/use/interactionAdapters.test.tsx +++ b/packages/extension/tests/webview/graph/runtime/use/interactionAdapters.test.tsx @@ -80,6 +80,7 @@ describe('graph/runtime/useGraphInteractionRuntime adapters', () => { renderHook(() => useGraphInteractionRuntime({ dataRef: { current: { edges: [], nodes: [] } as never }, depthMode: false, + graphViewVisible: true, fileInfoCacheRef: fileInfoCacheRef as never, graphContextSelection: createSelection(['src/selected.ts']), graphCursorRef: { current: 'pointer' as never }, @@ -113,7 +114,7 @@ describe('graph/runtime/useGraphInteractionRuntime adapters', () => { contextMenuOptions.openBackgroundContextMenu({ type: 'contextmenu' }); contextMenuOptions.setContextSelection({ kind: 'background', targets: [] }); contextMenuOptions.setTooltipData({ visible: true }); - contextMenuOptions.stopTooltipTracking(); + contextMenuOptions.clearTooltipAnchor(); expect(fileInfoCacheRef.current.has('src/stale.ts')).toBe(false); expect(interactionHandlers.fitView).toHaveBeenCalledTimes(1); @@ -121,7 +122,7 @@ describe('graph/runtime/useGraphInteractionRuntime adapters', () => { expect(interactionHandlers.openBackgroundContextMenu).toHaveBeenCalledWith({ type: 'contextmenu' }); expect(setContextSelection).toHaveBeenCalledWith({ kind: 'background', targets: [] }); expect(tooltipRuntime.setTooltipData).toHaveBeenCalledWith({ visible: true }); - expect(tooltipRuntime.stopTooltipTracking).toHaveBeenCalledTimes(1); + expect(tooltipRuntime.clearTooltipAnchor).toHaveBeenCalledTimes(1); }); it('passes viewport pan and marquee runtimes the shared graph interaction options', () => { diff --git a/packages/extension/tests/webview/graph/runtime/use/interactionDelegation.test.tsx b/packages/extension/tests/webview/graph/runtime/use/interactionDelegation.test.tsx index d81a38b03b..43580235c2 100644 --- a/packages/extension/tests/webview/graph/runtime/use/interactionDelegation.test.tsx +++ b/packages/extension/tests/webview/graph/runtime/use/interactionDelegation.test.tsx @@ -74,6 +74,7 @@ describe('graph/runtime/useGraphInteractionRuntime delegation', () => { ({ graphContextSelection }) => useGraphInteractionRuntime({ dataRef: { current: { edges: [], nodes: [] } as never }, depthMode: false, + graphViewVisible: true, fileInfoCacheRef: { current: new Map() } as never, graphContextSelection, graphCursorRef: { current: 'pointer' as never }, @@ -193,6 +194,7 @@ describe('graph/runtime/useGraphInteractionRuntime delegation', () => { ({ depthMode }) => useGraphInteractionRuntime({ dataRef: { current: { edges: [], nodes: [] } as never }, depthMode, + graphViewVisible: true, fileInfoCacheRef: { current: new Map() } as never, graphContextSelection: createSelection(['src/one.ts']), graphCursorRef: { current: 'pointer' as never }, diff --git a/packages/extension/tests/webview/graph/runtime/use/interactionDrag.test.tsx b/packages/extension/tests/webview/graph/runtime/use/interactionDrag.test.tsx index 39ed80d40e..80e7a4a637 100644 --- a/packages/extension/tests/webview/graph/runtime/use/interactionDrag.test.tsx +++ b/packages/extension/tests/webview/graph/runtime/use/interactionDrag.test.tsx @@ -74,6 +74,7 @@ describe('graph/runtime/useGraphInteractionRuntime drag', () => { const { result } = renderHook(() => useGraphInteractionRuntime({ dataRef: { current: { edges: [], nodes: [] } as never }, depthMode: false, + graphViewVisible: true, fileInfoCacheRef: { current: new Map() } as never, graphContextSelection: createSelection(['src/app.ts', 'src/util.ts']), graphCursorRef: { current: 'default' as never }, diff --git a/packages/extension/tests/webview/graph/runtime/use/interactionFixture.ts b/packages/extension/tests/webview/graph/runtime/use/interactionFixture.ts index c6f99d64f4..f3586267a6 100644 --- a/packages/extension/tests/webview/graph/runtime/use/interactionFixture.ts +++ b/packages/extension/tests/webview/graph/runtime/use/interactionFixture.ts @@ -32,7 +32,7 @@ export function createTooltipRuntime() { handleNodeHover: vi.fn(), hoveredNodeRef: { current: null }, setTooltipData: vi.fn(), - stopTooltipTracking: vi.fn(), + clearTooltipAnchor: vi.fn(), tooltipData: { visible: false }, tooltipTimeoutRef: { current: null }, }; @@ -90,6 +90,7 @@ export function createRuntimeOptions( return { dataRef: { current: { edges: [], nodes: [] } as never }, depthMode: false, + graphViewVisible: true, fileInfoCacheRef: { current: new Map() } as never, graphContextSelection: createSelection([]), graphCursorRef: { current: 'pointer' as never }, @@ -109,4 +110,3 @@ export function createRuntimeOptions( ...rest, }; } - diff --git a/packages/extension/tests/webview/graph/runtime/use/interactionLifecycle.test.tsx b/packages/extension/tests/webview/graph/runtime/use/interactionLifecycle.test.tsx index a61e1b4172..74d0b842a6 100644 --- a/packages/extension/tests/webview/graph/runtime/use/interactionLifecycle.test.tsx +++ b/packages/extension/tests/webview/graph/runtime/use/interactionLifecycle.test.tsx @@ -76,6 +76,7 @@ describe('graph/runtime/useGraphInteractionRuntime lifecycle', () => { const { unmount } = renderHook(() => useGraphInteractionRuntime({ dataRef: { current: { edges: [], nodes: [] } as never }, depthMode: false, + graphViewVisible: true, fileInfoCacheRef: { current: new Map() } as never, graphContextSelection: createSelection([]), graphCursorRef: { current: 'crosshair' as never }, @@ -199,6 +200,7 @@ describe('graph/runtime/useGraphInteractionRuntime lifecycle', () => { renderHook(() => useGraphInteractionRuntime({ dataRef: { current: { edges: [], nodes: [] } as never }, depthMode: false, + graphViewVisible: true, fileInfoCacheRef: { current: new Map() } as never, graphContextSelection: createSelection([]), graphCursorRef: { current: 'pointer' as never }, diff --git a/packages/extension/tests/webview/graph/runtime/use/tooltip/events/handlers.test.tsx b/packages/extension/tests/webview/graph/runtime/use/tooltip/events/handlers.test.tsx index 0732e2a92b..00a24dc486 100644 --- a/packages/extension/tests/webview/graph/runtime/use/tooltip/events/handlers.test.tsx +++ b/packages/extension/tests/webview/graph/runtime/use/tooltip/events/handlers.test.tsx @@ -49,22 +49,26 @@ afterEach(() => { }); describe('graph/runtime/useTooltipEvents', () => { - it('sets the cursor back to default on mouse leave', () => { + it('cancels tooltip tracking on mouse leave', () => { const setGraphCursor = vi.fn(); + const hoveredNodeRef = { current: createNode() as FGNode | null }; + const tooltipRectRef = { current: { x: 10, y: 20, radius: 8 } }; + const setTooltipData = vi.fn(); const { result } = renderHook(() => useTooltipEvents({ containerRef: { current: document.createElement('div') }, dataRef: { current: { nodes: [], edges: [] } } as never, fg2dRef: { current: undefined }, fileInfoCacheRef: { current: new Map() } as never, - hoveredNodeRef: { current: null }, + hoveredNodeRef, interactionHandlers: { setGraphCursor, }, postMessage: vi.fn(), - setTooltipData: vi.fn(), - tooltipRafRef: { current: null }, + setTooltipData, + tooltipRectRef, tooltipTimeoutRef: { current: null }, + graphViewVisible: true, })); act(() => { @@ -72,6 +76,97 @@ describe('graph/runtime/useTooltipEvents', () => { }); expect(setGraphCursor).toHaveBeenCalledWith('default'); + expect(hoveredNodeRef.current).toBeNull(); + expect(tooltipRectRef.current).toBeNull(); + expect(setTooltipData).toHaveBeenCalledWith(expect.any(Function)); + }); + + it.each(['window blur', 'hidden document'] as const)( + 'cancels tooltip tracking on %s', + (trigger) => { + const hoveredNodeRef = { current: createNode() as FGNode | null }; + const tooltipRectRef = { current: { x: 10, y: 20, radius: 8 } }; + const setTooltipData = vi.fn(); + renderHook(() => useTooltipEvents({ + containerRef: { current: document.createElement('div') }, + dataRef: { current: { nodes: [], edges: [] } } as never, + fg2dRef: { current: undefined }, + fileInfoCacheRef: { current: new Map() } as never, + hoveredNodeRef, + interactionHandlers: { setGraphCursor: vi.fn() }, + postMessage: vi.fn(), + setTooltipData, + tooltipRectRef, + tooltipTimeoutRef: { current: null }, + graphViewVisible: true, + })); + + act(() => { + if (trigger === 'window blur') { + window.dispatchEvent(new Event('blur')); + return; + } + Object.defineProperty(document, 'visibilityState', { + configurable: true, + value: 'hidden', + }); + document.dispatchEvent(new Event('visibilitychange')); + }); + + expect(hoveredNodeRef.current).toBeNull(); + expect(tooltipRectRef.current).toBeNull(); + expect(setTooltipData).toHaveBeenCalledWith(expect.any(Function)); + Object.defineProperty(document, 'visibilityState', { + configurable: true, + value: 'visible', + }); + }, + ); + + it('cancels tooltip tracking on disposal', () => { + const tooltipRectRef = { current: { x: 10, y: 20, radius: 8 } }; + const { unmount } = renderHook(() => useTooltipEvents({ + containerRef: { current: document.createElement('div') }, + dataRef: { current: { nodes: [], edges: [] } } as never, + fg2dRef: { current: undefined }, + fileInfoCacheRef: { current: new Map() } as never, + hoveredNodeRef: { current: createNode() }, + interactionHandlers: { setGraphCursor: vi.fn() }, + postMessage: vi.fn(), + setTooltipData: vi.fn(), + tooltipRectRef, + tooltipTimeoutRef: { current: null }, + graphViewVisible: true, + })); + + unmount(); + + expect(tooltipRectRef.current).toBeNull(); + }); + + it('cancels tooltip tracking when the Extension host hides the retained view', () => { + const hoveredNodeRef = { current: createNode() as FGNode | null }; + const tooltipRectRef = { current: { x: 10, y: 20, radius: 8 } }; + const options = { + containerRef: { current: document.createElement('div') }, + dataRef: { current: { nodes: [], edges: [] } } as never, + fg2dRef: { current: undefined }, + fileInfoCacheRef: { current: new Map() } as never, + hoveredNodeRef, + interactionHandlers: { setGraphCursor: vi.fn() }, + postMessage: vi.fn(), + setTooltipData: vi.fn(), + tooltipRectRef, + tooltipTimeoutRef: { current: null }, + graphViewVisible: true, + }; + const { rerender } = renderHook(useTooltipEvents, { initialProps: options }); + + rerender({ ...options, graphViewVisible: false }); + + expect(hoveredNodeRef.current).toBeNull(); + expect(tooltipRectRef.current).toBeNull(); + expect(options.setTooltipData).toHaveBeenCalledWith(expect.any(Function)); }); it('uses the latest handlers after rerender instead of stale callback dependencies', () => { @@ -94,8 +189,9 @@ describe('graph/runtime/useTooltipEvents', () => { }, postMessage: firstPostMessage, setTooltipData: firstSetTooltipData, - tooltipRafRef: { current: null }, + tooltipRectRef: { current: null }, tooltipTimeoutRef: { current: null }, + graphViewVisible: true, }; const { result, rerender } = renderHook(useTooltipEvents, { initialProps, diff --git a/packages/extension/tests/webview/graph/runtime/use/tooltip/events/state.test.tsx b/packages/extension/tests/webview/graph/runtime/use/tooltip/events/state.test.tsx index 3aaf0ef2bd..29bc8919e0 100644 --- a/packages/extension/tests/webview/graph/runtime/use/tooltip/events/state.test.tsx +++ b/packages/extension/tests/webview/graph/runtime/use/tooltip/events/state.test.tsx @@ -8,7 +8,7 @@ describe('graph/runtime/useTooltipState', () => { expect(result.current.hoveredNodeRef.current).toBeNull(); expect(result.current.tooltipTimeoutRef.current).toBeNull(); - expect(result.current.tooltipRafRef.current).toBeNull(); + expect(result.current.tooltipRectRef.current).toBeNull(); expect(result.current.tooltipData).toEqual({ visible: false, nodeRect: { x: 0, y: 0, radius: 0 }, diff --git a/packages/extension/tests/webview/graph/runtime/use/tooltip/hook.test.tsx b/packages/extension/tests/webview/graph/runtime/use/tooltip/hook.test.tsx index d4f1eb71ac..0623ffaea1 100644 --- a/packages/extension/tests/webview/graph/runtime/use/tooltip/hook.test.tsx +++ b/packages/extension/tests/webview/graph/runtime/use/tooltip/hook.test.tsx @@ -36,6 +36,7 @@ describe('graph/runtime/useGraphTooltip', () => { setGraphCursor, }, postMessage: vi.fn(), + graphViewVisible: true, })); act(() => { diff --git a/packages/extension/tests/webview/graph/viewport/model.test.tsx b/packages/extension/tests/webview/graph/viewport/model.test.tsx index 83608623e6..e8b0b27fec 100644 --- a/packages/extension/tests/webview/graph/viewport/model.test.tsx +++ b/packages/extension/tests/webview/graph/viewport/model.test.tsx @@ -82,7 +82,7 @@ function createInteractions(): UseGraphInteractionRuntimeResult { zoomGraphView: vi.fn(), }, setTooltipData: vi.fn(), - stopTooltipTracking: vi.fn(), + clearTooltipAnchor: vi.fn(), tooltipData: { visible: false, nodeRect: { x: 0, y: 0, radius: 0 }, @@ -92,6 +92,7 @@ function createInteractions(): UseGraphInteractionRuntimeResult { pluginSections: [], }, tooltipTimeoutRef: { current: null }, + updateTooltipAnchor: vi.fn(), } as unknown as UseGraphInteractionRuntimeResult; } @@ -179,4 +180,40 @@ describe('graph/viewport/model', () => { graphData: nextGraphData, })); }); + + it('keeps graph surface props stable when only tooltip state changes', () => { + const interactions = createInteractions(); + const graphData = createGraphData(); + const handleEngineStop = vi.fn(); + const viewportRuntime = { containerSize: { width: 480, height: 320 } }; + const viewState = createViewState(); + const { result, rerender } = renderHook( + ({ currentInteractions }) => useGraphViewportModel({ + graphState: { + contextSelection: { kind: 'background', targets: [] }, + graphData, + }, + handleEngineStop, + interactions: currentInteractions, + viewportRuntime, + viewState, + }), + { initialProps: { currentInteractions: interactions } }, + ); + const initialSharedProps = result.current.sharedProps; + + rerender({ + currentInteractions: { + ...interactions, + tooltipData: { + ...interactions.tooltipData, + nodeRect: { x: 50, y: 60, radius: 12 }, + visible: true, + }, + }, + }); + + expect(harness.buildSharedGraphProps).toHaveBeenCalledOnce(); + expect(result.current.sharedProps).toBe(initialSharedProps); + }); }); diff --git a/packages/extension/tests/webview/graph/viewport/shell.tooltipSurface.integration.test.tsx b/packages/extension/tests/webview/graph/viewport/shell.tooltipSurface.integration.test.tsx new file mode 100644 index 0000000000..60f3564996 --- /dev/null +++ b/packages/extension/tests/webview/graph/viewport/shell.tooltipSurface.integration.test.tsx @@ -0,0 +1,106 @@ +import React from 'react'; +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; +import { act, render, screen } from '@testing-library/react'; +import type { OwnedGraph2dControls } from '../../../../src/webview/components/graph/rendering/surface/owned2d/view/surface/contracts'; +import type { FGNode } from '../../../../src/webview/components/graph/model/build'; +import { useGraphTooltip } from '../../../../src/webview/components/graph/runtime/use/tooltip/hook'; + +const surfaceHarness = vi.hoisted(() => ({ + renderFramePost: undefined as undefined | ((ctx: CanvasRenderingContext2D, globalScale: number) => void), + renders: vi.fn(), +})); + +vi.mock('../../../../src/webview/components/graph/rendering/surface/owned2d/view/surface/render', () => ({ + OwnedGraphSurface2d: (props: { onRenderFramePost(ctx: CanvasRenderingContext2D, globalScale: number): void }) => { + surfaceHarness.renderFramePost = props.onRenderFramePost; + surfaceHarness.renders(); + requestAnimationFrame(() => undefined); + return
; + }, +})); + +import { + createCallbacks, + createGraphData, + createGraphViewportShellElement, + createGraphState, + createInteractions, + createViewState, + renderActualViewport, + resetShellHarness, +} from './shellFixture'; + +describe('GraphViewportShell tooltip surface boundary', () => { + afterEach(() => { + vi.useRealTimers(); + vi.unstubAllGlobals(); + }); + + beforeEach(() => { + resetShellHarness(); + renderActualViewport(); + surfaceHarness.renderFramePost = undefined; + surfaceHarness.renders.mockReset(); + vi.stubGlobal('requestAnimationFrame', vi.fn(() => 1)); + }); + + it('submits zero repeated graph frames when real tooltip state shows a stationary Node tooltip', () => { + vi.useFakeTimers({ toFake: ['setTimeout', 'clearTimeout'] }); + const graphState = createGraphState(createGraphData()); + graphState.renderer.fg2dRef.current = { + graph2ScreenCoords: vi.fn(() => ({ x: 50, y: 60 })), + zoom: vi.fn(() => 1), + } as unknown as OwnedGraph2dControls; + const baseInteractions = createInteractions(); + const callbacks = createCallbacks(); + const viewState = createViewState(); + const handleEngineStop = vi.fn(); + const postMessage = vi.fn(); + const setGraphCursor = vi.fn(); + let handleNodeHover: ((node: FGNode | null) => void) | undefined; + + function TooltipStateHarness() { + const tooltip = useGraphTooltip({ + containerRef: graphState.renderer.containerRef, + dataRef: graphState.dataRef, + fg2dRef: graphState.renderer.fg2dRef, + fileInfoCacheRef: graphState.renderCaches.fileInfoCacheRef, + graphViewVisible: true, + interactionHandlers: { setGraphCursor }, + postMessage, + }); + handleNodeHover = tooltip.handleNodeHover; + return createGraphViewportShellElement({ + callbacks, + graphDataLayoutKey: 'connections::', + graphState, + handleEngineStop, + interactions: { ...baseInteractions, ...tooltip }, + theme: 'light', + viewState, + }); + } + + render(); + expect(surfaceHarness.renders).toHaveBeenCalledOnce(); + expect(requestAnimationFrame).toHaveBeenCalledOnce(); + expect(screen.queryByText('src/app.ts')).not.toBeInTheDocument(); + + act(() => { + handleNodeHover?.(graphState.renderer.graphData.nodes[0]); + vi.advanceTimersByTime(500); + }); + + expect(screen.getByText('src/app.ts')).toBeVisible(); + expect(surfaceHarness.renders).toHaveBeenCalledOnce(); + expect(requestAnimationFrame).toHaveBeenCalledOnce(); + + act(() => { + surfaceHarness.renderFramePost?.({} as CanvasRenderingContext2D, 1); + surfaceHarness.renderFramePost?.({} as CanvasRenderingContext2D, 1); + }); + + expect(surfaceHarness.renders).toHaveBeenCalledOnce(); + expect(requestAnimationFrame).toHaveBeenCalledOnce(); + }); +}); diff --git a/packages/extension/tests/webview/graph/viewport/shellFixture.tsx b/packages/extension/tests/webview/graph/viewport/shellFixture.tsx index 8a32bfccf9..718a00c1d0 100644 --- a/packages/extension/tests/webview/graph/viewport/shellFixture.tsx +++ b/packages/extension/tests/webview/graph/viewport/shellFixture.tsx @@ -1,4 +1,4 @@ -import React from 'react'; +import React, { type ComponentProps, type ReactElement } from 'react'; import { render } from '@testing-library/react'; import { vi } from 'vitest'; import type { IGraphData } from '../../../../src/shared/graph/contracts'; @@ -7,6 +7,7 @@ import type { GraphViewStoreState } from '../../../../src/webview/components/gra import type { UseGraphInteractionRuntimeResult } from '../../../../src/webview/components/graph/runtime/use/interaction'; import type { GraphRuntime } from '../../../../src/webview/components/graph/runtime/use/state'; import { GraphViewportShell } from '../../../../src/webview/components/graph/viewport/shell'; +import type { ViewportProps } from '../../../../src/webview/components/graph/viewport/contracts'; import { graphStore } from '../../../../src/webview/store/state'; const shellHarness = vi.hoisted(() => ({ @@ -14,7 +15,8 @@ const shellHarness = vi.hoisted(() => ({ useGraphEventEffects: vi.fn(), useGraphRenderingRuntime: vi.fn(), useGraphViewportModel: vi.fn(), - viewport: vi.fn((_props: Record) =>
), + renderActualViewport: false, + viewport: vi.fn((_props: ViewportProps) =>
), })); vi.mock('../../../../src/webview/vscodeApi', () => ({ @@ -33,12 +35,20 @@ vi.mock('../../../../src/webview/components/graph/viewport/model', () => ({ useGraphViewportModel: shellHarness.useGraphViewportModel, })); -vi.mock('../../../../src/webview/components/graph/viewport/view', () => ({ - Viewport: (props: Record) => { - shellHarness.viewport(props); - return
; - }, -})); +vi.mock('../../../../src/webview/components/graph/viewport/view', async importOriginal => { + const actual = await importOriginal< + typeof import('../../../../src/webview/components/graph/viewport/view') + >(); + return { + ...actual, + Viewport: (props: Parameters[0]) => { + shellHarness.viewport(props); + return shellHarness.renderActualViewport + ? + :
; + }, + }; +}); export function createGraphData(): GraphRuntime['renderer']['graphData'] { return { @@ -174,8 +184,9 @@ export function createInteractions(): UseGraphInteractionRuntimeResult { }, contextMenuRuntime: {} as never, hoveredNodeRef: { current: null }, - stopTooltipTracking: vi.fn(), + clearTooltipAnchor: vi.fn(), tooltipTimeoutRef: { current: null }, + updateTooltipAnchor: vi.fn(), } as unknown as UseGraphInteractionRuntimeResult; } @@ -206,7 +217,7 @@ export function createCallbacks() { export function createViewState(): Pick< GraphViewStoreState, - 'bidirectionalMode' | 'depthMode' | 'directionMode' | 'favorites' | 'nodeSizeMode' | 'particleSize' | 'particleSpeed' | 'physicsSettings' | 'showFps' | 'showLabels' | 'showMinimap' + 'bidirectionalMode' | 'depthMode' | 'directionMode' | 'favorites' | 'graphViewVisible' | 'nodeSizeMode' | 'particleSize' | 'particleSpeed' | 'physicsSettings' | 'showFps' | 'showLabels' | 'showMinimap' > { const physicsSettings: IPhysicsSettings = { centerForce: 0.1, @@ -220,6 +231,7 @@ export function createViewState(): Pick< depthMode: false, directionMode: 'arrows', favorites: new Set(['src/app.ts']), + graphViewVisible: true, nodeSizeMode: 'connections', particleSize: 3, particleSpeed: 0.2, @@ -236,6 +248,7 @@ export function resetShellHarness(): void { shellHarness.useGraphRenderingRuntime.mockReset(); shellHarness.useGraphViewportModel.mockReset(); shellHarness.viewport.mockReset(); + shellHarness.renderActualViewport = false; graphStore.getState().setGraphViewportScale(null); shellHarness.useGraphRenderingRuntime.mockReturnValue({ containerSize: { height: 320, width: 480 }, @@ -255,6 +268,16 @@ export function resetShellHarness(): void { }); } +export function renderActualViewport(): void { + shellHarness.renderActualViewport = true; +} + +export function createGraphViewportShellElement( + props: ComponentProps, +): ReactElement { + return ; +} + export function renderGraphViewportShell(options: { callbacks?: ReturnType; graphState?: GraphRuntime; diff --git a/packages/extension/tests/webview/store/messageHandlers/graph/fixture.ts b/packages/extension/tests/webview/store/messageHandlers/graph/fixture.ts index d2833f4d09..16bd41d1e0 100644 --- a/packages/extension/tests/webview/store/messageHandlers/graph/fixture.ts +++ b/packages/extension/tests/webview/store/messageHandlers/graph/fixture.ts @@ -5,6 +5,7 @@ export function createState( ): IStoreFields { return { graphData: null, + graphViewVisible: true, graphHasIndex: false, graphIndexFreshness: 'missing', graphIndexDetail: null, diff --git a/packages/extension/tests/webview/store/state/messageRouting.test.ts b/packages/extension/tests/webview/store/state/messageRouting.test.ts index eb3e7b0ab8..e20da6dd96 100644 --- a/packages/extension/tests/webview/store/state/messageRouting.test.ts +++ b/packages/extension/tests/webview/store/state/messageRouting.test.ts @@ -12,6 +12,15 @@ describe('GraphStore message routing', () => { clearSentMessages(); }); + it('tracks Extension-owned Graph View visibility', () => { + store.getState().handleExtensionMessage({ + type: 'GRAPH_VIEW_VISIBILITY_UPDATED', + payload: { visible: false }, + }); + + expect(store.getState().graphViewVisible).toBe(false); + }); + it('handles DECORATIONS_UPDATED messages', () => { const nodeDecorations: Record = { 'src/app.ts': { color: '#00ff00' },