From 28036a9deb6e7e204e5c860706ed7706e0a3e5d4 Mon Sep 17 00:00:00 2001 From: joesobo Date: Thu, 6 Aug 2026 10:24:59 -0700 Subject: [PATCH 1/6] fix(extension): stop stationary tooltip graph frames --- .changeset/calm-tooltips-rest.md | 5 + .../surface/owned2d/frame/runtime/loop.ts | 29 +++- .../graph/runtime/tooltip/tracking.ts | 53 ++++--- .../runtime/use/interaction/contracts.ts | 1 + .../graph/runtime/use/interaction/hook.ts | 20 ++- .../runtime/use/interaction/marquee/hook.ts | 5 +- .../runtime/use/tooltip/events/handlers.ts | 108 +++++++------ .../graph/runtime/use/tooltip/events/state.ts | 8 +- .../graph/runtime/use/tooltip/hook.ts | 7 +- .../components/graph/viewport/model.ts | 10 +- .../components/graph/viewport/shell.tsx | 1 + .../tests/webview/Graph.wiring.fixture.ts | 1 + .../owned2d/frame/runtime/loop.test.ts | 43 ++++++ .../view/behavior/performance.test.tsx | 70 +++++++++ .../graph/runtime/tooltip/tracking.test.ts | 143 ++++++------------ .../use/tooltip/events/handlers.test.tsx | 77 +++++++++- .../runtime/use/tooltip/events/state.test.tsx | 2 +- .../webview/graph/viewport/model.test.tsx | 37 +++++ .../webview/graph/viewport/shellFixture.tsx | 1 + 19 files changed, 432 insertions(+), 189 deletions(-) create mode 100644 .changeset/calm-tooltips-rest.md 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/webview/components/graph/rendering/surface/owned2d/frame/runtime/loop.ts b/packages/extension/src/webview/components/graph/rendering/surface/owned2d/frame/runtime/loop.ts index 98ed462cc0..455fae6f2e 100644 --- a/packages/extension/src/webview/components/graph/rendering/surface/owned2d/frame/runtime/loop.ts +++ b/packages/extension/src/webview/components/graph/rendering/surface/owned2d/frame/runtime/loop.ts @@ -23,9 +23,14 @@ export interface OwnedGraphFrameLoop { dispose(): void; } -function canScheduleFrame(runtime: OwnedGraphFrameLoopRuntime, active: boolean): boolean { +function canScheduleFrame( + runtime: OwnedGraphFrameLoopRuntime, + active: boolean, + visible: boolean, +): boolean { const renderer = runtime.gpuRendererRef.current; return active + && visible && runtime.animationFrameRef.current === null && (!renderer || renderer.canRender()); } @@ -36,6 +41,7 @@ export function startOwnedGraphFrameLoop( controlsRef: MutableRefObject, ): OwnedGraphFrameLoop { let active = true; + let visible = document.visibilityState !== 'hidden'; const frameRuntime: OwnedGraphFrameRuntime = { ...runtime, recordRenderedFrame(submissionId, timestamp, simulationMs, renderMs) { @@ -54,17 +60,31 @@ export function startOwnedGraphFrameLoop( const renderFrame = (timestamp: number): void => { runtime.animationFrameRef.current = null; + if (!active || !visible) return; runtime.frameRequestedRef.current = false; - if (!active) return; renderOwnedGraphFrame(frameRuntime, canvas, timestamp); }; + const scheduleRequestedFrame = (): void => { + if (canScheduleFrame(runtime, active, visible)) { + 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 handleVisibilityChange = (): void => { + visible = document.visibilityState !== 'hidden'; + if (!visible && runtime.animationFrameRef.current !== null) { + window.cancelAnimationFrame(runtime.animationFrameRef.current); + runtime.animationFrameRef.current = null; } + if (visible && runtime.frameRequestedRef.current) scheduleRequestedFrame(); }; + document.addEventListener('visibilitychange', handleVisibilityChange); const controls = createOwnedGraphControls(runtime, canvas); controlsRef.current = controls; @@ -78,6 +98,7 @@ export function startOwnedGraphFrameLoop( return { dispose: () => { active = false; + document.removeEventListener('visibilitychange', handleVisibilityChange); resizeObserver.disconnect(); stopObservingDevicePixelRatio(); if (runtime.animationFrameRef.current !== null) { diff --git a/packages/extension/src/webview/components/graph/runtime/tooltip/tracking.ts b/packages/extension/src/webview/components/graph/runtime/tooltip/tracking.ts index aca44ebe53..5d0ae80b6e 100644 --- a/packages/extension/src/webview/components/graph/runtime/tooltip/tracking.ts +++ b/packages/extension/src/webview/components/graph/runtime/tooltip/tracking.ts @@ -8,36 +8,51 @@ import type { FGNode } from '../../model/build'; interface GraphTooltipTrackingOptions { getNodeRect(this: void, node: FGNode): GraphTooltipRect | null; hoveredNodeRef: MutableRefObject; - setTooltipData: React.Dispatch>; - tooltipRafRef: MutableRefObject; + tooltipRectRef: MutableRefObject; } export function stopTooltipTracking( - tooltipRafRef: MutableRefObject, + tooltipRectRef: MutableRefObject, ): void { - if (tooltipRafRef.current !== null) { - cancelAnimationFrame(tooltipRafRef.current); - tooltipRafRef.current = null; - } + tooltipRectRef.current = null; } export function startTooltipTracking({ 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 updateTooltipTracking({ + getNodeRect, + hoveredNodeRef, + setTooltipData, + tooltipRectRef, +}: GraphTooltipTrackingOptions & { + setTooltipData: React.Dispatch>; +}): void { + const node = hoveredNodeRef.current; + const rect = node ? getNodeRect(node) : null; + if (!rect) { + stopTooltipTracking(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..3fcd92d46e 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 @@ -69,4 +69,5 @@ export interface UseGraphInteractionRuntimeResult { stopTooltipTracking: ReturnType['stopTooltipTracking']; 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..9a98d57d1a 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 @@ -111,6 +111,7 @@ export function useGraphInteractionRuntime({ stopTooltipTracking, tooltipData, tooltipTimeoutRef, + updateTooltipAnchor, } = useGraphTooltip({ containerRef: refs.containerRef, dataRef, @@ -135,6 +136,7 @@ export function useGraphInteractionRuntime({ interactionHandlers, selectedNodesSetRef: refs.selectedNodesSetRef, }); + const { clearMarqueeSelection } = marqueeRuntime; const getActionContext = useCallback( (selection: GraphContextSelection) => resolveGraphContextActionContext(selection, { @@ -144,22 +146,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({ @@ -244,7 +251,7 @@ export function useGraphInteractionRuntime({ ...contextMenuOpeningRuntime, handleBackgroundRightClick: suppressedContextMenuHandlers.handleBackgroundRightClick, handleContextMenu: suppressedContextMenuHandlers.handleContextMenu, - handleEngineStop: () => postMessage({ type: 'PHYSICS_STABILIZED' }), + handleEngineStop, handleLinkRightClick: suppressedContextMenuHandlers.handleLinkRightClick, handleMouseLeave: handleGraphMouseLeave, handleNodeHover, @@ -261,5 +268,6 @@ export function useGraphInteractionRuntime({ stopTooltipTracking, 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..1a0fe82052 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,7 @@ import { + useCallback, useEffect, + useRef, type MutableRefObject, } from 'react'; import type { OwnedGraph2dControls } from '../../../../rendering/surface/owned2d/view/surface/contracts'; @@ -8,7 +10,7 @@ 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'; @@ -16,6 +18,7 @@ import { getTooltipNodeRect } from '../../../tooltip/rect'; import { startTooltipTracking as beginTooltipTracking, stopTooltipTracking as endTooltipTracking, + updateTooltipTracking, } from '../../../tooltip/tracking'; export interface UseTooltipEventsOptions { @@ -29,7 +32,7 @@ export interface UseTooltipEventsOptions { pluginHost?: WebviewPluginHost; postMessage: (this: void, message: WebviewToExtensionMessage) => void; setTooltipData: React.Dispatch>; - tooltipRafRef: MutableRefObject; + tooltipRectRef: MutableRefObject; tooltipTimeoutRef: MutableRefObject | null>; } @@ -37,75 +40,82 @@ export interface UseTooltipEventsResult { handleMouseLeave: (this: void) => void; handleNodeHover: (this: void, node: FGNode | null) => void; stopTooltipTracking: (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); + optionsRef.current = options; + const getNodeScreenRect = useCallback((node: FGNode) => { + const current = optionsRef.current; + return getTooltipNodeRect({ + containerRef: current.containerRef, + fg2dRef: current.fg2dRef, + }, node); + }, []); - const stopTooltipTracking = () => { - endTooltipTracking(tooltipRafRef); - }; + const stopTooltipTracking = useCallback(() => { + endTooltipTracking(optionsRef.current.tooltipRectRef); + }, []); - const startTooltipTracking = () => { + const startTooltipTracking = useCallback(() => { + const current = optionsRef.current; beginTooltipTracking({ 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, }); - }; + }, [getNodeScreenRect, startTooltipTracking, stopTooltipTracking]); - const handleMouseLeave = () => { - interactionHandlers.setGraphCursor('default'); - }; + const handleMouseLeave = useCallback(() => { + handleNodeHover(null); + }, [handleNodeHover]); + + const updateTooltipAnchor = useCallback(() => { + const current = optionsRef.current; + updateTooltipTracking({ + 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; + } + endTooltipTracking(current.tooltipRectRef); + }; }, - [tooltipRafRef, tooltipTimeoutRef], + [handleMouseLeave, handleNodeHover], ); return { handleMouseLeave, handleNodeHover, stopTooltipTracking, + 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..d5aa5df343 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 @@ -34,6 +34,7 @@ export interface UseGraphTooltipResult { stopTooltipTracking: (this: void) => void; tooltipData: GraphTooltipState; tooltipTimeoutRef: MutableRefObject | null>; + updateTooltipAnchor: (this: void) => void; } export function useGraphTooltip({ @@ -50,13 +51,14 @@ export function useGraphTooltip({ hoveredNodeRef, setTooltipData, tooltipData, - tooltipRafRef, + tooltipRectRef, tooltipTimeoutRef, } = useTooltipState(); const { handleMouseLeave, handleNodeHover, stopTooltipTracking, + updateTooltipAnchor, } = useTooltipEvents({ containerRef, dataRef, @@ -68,7 +70,7 @@ export function useGraphTooltip({ pluginHost, postMessage, setTooltipData, - tooltipRafRef, + tooltipRectRef, tooltipTimeoutRef, }); @@ -80,5 +82,6 @@ export function useGraphTooltip({ stopTooltipTracking, tooltipData, tooltipTimeoutRef, + updateTooltipAnchor, }; } 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/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/rendering/surface/owned2d/frame/runtime/loop.test.ts b/packages/extension/tests/webview/graph/rendering/surface/owned2d/frame/runtime/loop.test.ts index 815807e577..68f41ccc13 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 @@ -137,4 +137,47 @@ describe('owned graph frame loop', () => { runtime.requestFrameRef.current(); expect(requestAnimationFrame).toHaveBeenCalledOnce(); }); + + it('performs zero graph frames and zero physics work while the hovered graph is hidden', () => { + vi.mocked(renderOwnedGraphFrame).mockClear(); + Object.defineProperty(document, 'visibilityState', { + configurable: true, + value: 'hidden', + }); + 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: {} }, + rendererOperationalRef: { current: true }, + requestFrameRef: { current: () => {} }, + } as unknown as OwnedGraphFrameLoopRuntime; + + const loop = startOwnedGraphFrameLoop( + runtime, + document.createElement('canvas'), + { current: undefined }, + ); + runtime.requestFrameRef.current(); + + expect(runtime.frameRequestedRef.current).toBe(true); + expect(requestAnimationFrame).not.toHaveBeenCalled(); + expect(renderOwnedGraphFrame).not.toHaveBeenCalled(); + + Object.defineProperty(document, 'visibilityState', { + configurable: true, + value: 'visible', + }); + document.dispatchEvent(new Event('visibilitychange')); + expect(requestAnimationFrame).toHaveBeenCalledOnce(); + + loop.dispose(); + }); }); diff --git a/packages/extension/tests/webview/graph/rendering/surface/owned2d/view/behavior/performance.test.tsx b/packages/extension/tests/webview/graph/rendering/surface/owned2d/view/behavior/performance.test.tsx index c33c60b216..e9b81d3d29 100644 --- a/packages/extension/tests/webview/graph/rendering/surface/owned2d/view/behavior/performance.test.tsx +++ b/packages/extension/tests/webview/graph/rendering/surface/owned2d/view/behavior/performance.test.tsx @@ -20,6 +20,8 @@ vi.mock('@codegraphy-dev/graph-renderer', async importOriginal => ({ })); import { OwnedGraphSurface2d } from '../../../../../../../../src/webview/components/graph/rendering/surface/owned2d/view/surface/render'; +import { startTooltipTracking } from '../../../../../../../../src/webview/components/graph/runtime/tooltip/tracking'; +import type { FGNode } from '../../../../../../../../src/webview/components/graph/model/build'; describe('OwnedGraphSurface2d performance presentation', () => { afterEach(() => { @@ -35,6 +37,74 @@ describe('OwnedGraphSurface2d performance presentation', () => { vi.stubGlobal('PointerEvent', MouseEvent); }); + it('submits zero repeated graph frames for a settled stationary Node tooltip', async () => { + const frames: FrameRequestCallback[] = []; + vi.mocked(requestAnimationFrame).mockImplementation(callback => { + frames.push(callback); + return frames.length; + }); + const context = { + clearRect: vi.fn(), + restore: vi.fn(), + save: vi.fn(), + scale: vi.fn(), + setTransform: vi.fn(), + translate: vi.fn(), + } as unknown as CanvasRenderingContext2D; + rendererHarness.render.mockReturnValue(1); + rendererHarness.create.mockResolvedValue({ + canRender: () => true, + dispose: rendererHarness.dispose, + render: rendererHarness.render, + setSecondarySurface: rendererHarness.setSecondarySurface, + }); + const props = createDefaultSurfaceProps(); + props.showMinimap = false; + const rendered = render(); + const overlay = rendered.container.querySelectorAll('canvas')[1]; + Object.defineProperty(overlay, 'getContext', { value: () => context }); + await waitFor(() => { + expect(rendered.container.firstElementChild).toHaveAttribute( + 'data-codegraphy-renderer', + 'webgpu', + ); + }); + + let timestamp = 0; + let drainedFrames = 0; + while (frames.length > 0 && drainedFrames < 100) { + const frame = frames.shift(); + act(() => frame?.(timestamp)); + timestamp += 1000 / 60; + drainedFrames += 1; + } + expect(drainedFrames).toBeLessThan(100); + expect(frames).toHaveLength(0); + const settledSubmissions = rendererHarness.render.mock.calls.length; + const settledFrameRequests = vi.mocked(requestAnimationFrame).mock.calls.length; + + const hoveredNode: FGNode = { + baseOpacity: 1, + borderColor: '#000', + borderWidth: 1, + color: '#fff', + id: 'src/app.ts', + isFavorite: false, + isPinned: false, + label: 'app.ts', + size: 12, + }; + startTooltipTracking({ + getNodeRect: () => ({ x: 120, y: 80, radius: 12 }), + hoveredNodeRef: { current: hoveredNode }, + tooltipRectRef: { current: null }, + }); + + expect(rendererHarness.render).toHaveBeenCalledTimes(settledSubmissions); + expect(requestAnimationFrame).toHaveBeenCalledTimes(settledFrameRequests); + expect(frames).toHaveLength(0); + }); + it('shows actual rendered FPS separately from CPU frame cost while tracking remains always on', async () => { const frames: FrameRequestCallback[] = []; vi.mocked(requestAnimationFrame).mockImplementation(callback => { 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..5eb5f51c78 100644 --- a/packages/extension/tests/webview/graph/runtime/tooltip/tracking.test.ts +++ b/packages/extension/tests/webview/graph/runtime/tooltip/tracking.test.ts @@ -5,38 +5,34 @@ import type { GraphTooltipState } from '../../../../../src/webview/components/gr import { startTooltipTracking, stopTooltipTracking, + updateTooltipTracking, } 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 }; + it('clears the tracked anchor', () => { + const tooltipRectRef = { current: { x: 10, y: 20, radius: 30 } }; - stopTooltipTracking(tooltipRafRef); + stopTooltipTracking(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('starts tracking without owning an animation frame', () => { + const requestAnimationFrameSpy = vi.fn(); + vi.stubGlobal('requestAnimationFrame', requestAnimationFrameSpy); + const tooltipRectRef = { current: null }; - stopTooltipTracking(tooltipRafRef); + startTooltipTracking({ + 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 tracked 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({ + updateTooltipTracking({ 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); + updateTooltipTracking(options); + updateTooltipTracking(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>; + updateTooltipTracking({ + 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 }; + updateTooltipTracking({ + 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/tooltip/events/handlers.test.tsx b/packages/extension/tests/webview/graph/runtime/use/tooltip/events/handlers.test.tsx index 0732e2a92b..d3f1fa2976 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,21 +49,24 @@ 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 }, })); @@ -72,6 +75,70 @@ 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 }, + })); + + 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 }, + })); + + unmount(); + + expect(tooltipRectRef.current).toBeNull(); }); it('uses the latest handlers after rerender instead of stale callback dependencies', () => { @@ -94,7 +161,7 @@ describe('graph/runtime/useTooltipEvents', () => { }, postMessage: firstPostMessage, setTooltipData: firstSetTooltipData, - tooltipRafRef: { current: null }, + tooltipRectRef: { current: null }, tooltipTimeoutRef: { current: null }, }; const { result, rerender } = renderHook(useTooltipEvents, { 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/viewport/model.test.tsx b/packages/extension/tests/webview/graph/viewport/model.test.tsx index 83608623e6..61b39a95fb 100644 --- a/packages/extension/tests/webview/graph/viewport/model.test.tsx +++ b/packages/extension/tests/webview/graph/viewport/model.test.tsx @@ -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/shellFixture.tsx b/packages/extension/tests/webview/graph/viewport/shellFixture.tsx index 8a32bfccf9..b8e3e53b25 100644 --- a/packages/extension/tests/webview/graph/viewport/shellFixture.tsx +++ b/packages/extension/tests/webview/graph/viewport/shellFixture.tsx @@ -176,6 +176,7 @@ export function createInteractions(): UseGraphInteractionRuntimeResult { hoveredNodeRef: { current: null }, stopTooltipTracking: vi.fn(), tooltipTimeoutRef: { current: null }, + updateTooltipAnchor: vi.fn(), } as unknown as UseGraphInteractionRuntimeResult; } From f6389e356d5d149b9f98ae6bfa318ccecc15c9c8 Mon Sep 17 00:00:00 2001 From: joesobo Date: Thu, 6 Aug 2026 10:45:57 -0700 Subject: [PATCH 2/6] fix(extension): pause hidden graph views --- .../src/extension/graphView/editorPanel.ts | 5 + .../extension/graphView/webview/resolve.ts | 5 + .../extension/graphView/webview/visibility.ts | 13 ++ .../src/shared/protocol/extensionToWebview.ts | 1 + .../graph/contextMenuOpening/runtime.ts | 6 +- .../graph/contextMenuRuntime/controller.ts | 2 +- .../graph/contextMenuRuntime/tooltip.ts | 4 +- .../surface/owned2d/frame/runtime/loop.ts | 24 ++- .../surface/owned2d/view/surface/contracts.ts | 1 + .../surface/owned2d/view/surface/render.tsx | 12 +- .../graph/runtime/tooltip/tracking.ts | 8 +- .../runtime/use/interaction/contracts.ts | 3 +- .../graph/runtime/use/interaction/hook.ts | 10 +- .../runtime/use/tooltip/events/handlers.ts | 33 ++-- .../graph/runtime/use/tooltip/hook.ts | 9 +- .../components/graph/view/component.tsx | 1 + .../webview/components/graph/view/store.ts | 2 + .../graph/viewport/shell/surfaceProps.ts | 3 +- .../graph/viewport/surface/equality.ts | 1 + .../src/webview/store/initialState.ts | 1 + .../src/webview/store/messageTypes.ts | 1 + .../extension/src/webview/store/messages.ts | 6 + packages/extension/src/webview/store/state.ts | 1 + .../extension/graphView/editorPanel.test.ts | 18 +- .../graphView/webview/resolve.test.ts | 12 ++ .../graphViewProvider.webviewApi.test.ts | 3 + .../tooltip-frame-lifecycle.spec.ts | 186 ++++++++++++++++++ .../graph/contextMenuOpening/runtime.test.ts | 6 +- ...pliescontexteffectsdirectlythrough.test.ts | 2 +- ...whenthetoappliesbuiltinmenuactions.test.ts | 6 +- .../contextMenuRuntime/controller.test.ts | 2 +- .../fallback/scheduler.test.ts | 2 +- .../graph/contextMenuRuntime/pointer.test.ts | 2 +- .../graph/contextMenuRuntime/tooltip.test.ts | 2 +- .../owned2d/frame/runtime/loop.test.ts | 20 +- .../loop.visibility.integration.test.ts | 74 +++++++ .../view/behavior/performance.test.tsx | 70 ------- .../surface/owned2d/view/surface/fixture.ts | 1 + .../graph/runtime/tooltip/tracking.test.ts | 20 +- .../runtime/use/interactionAdapters.test.tsx | 5 +- .../use/interactionDelegation.test.tsx | 2 + .../runtime/use/interactionDrag.test.tsx | 1 + .../graph/runtime/use/interactionFixture.ts | 4 +- .../runtime/use/interactionLifecycle.test.tsx | 2 + .../use/tooltip/events/handlers.test.tsx | 29 +++ .../graph/runtime/use/tooltip/hook.test.tsx | 1 + .../webview/graph/viewport/model.test.tsx | 2 +- .../shell.tooltipSurface.integration.test.tsx | 74 +++++++ .../webview/graph/viewport/shellFixture.tsx | 34 +++- .../store/messageHandlers/graph/fixture.ts | 1 + .../store/state/messageRouting.test.ts | 9 + 51 files changed, 585 insertions(+), 157 deletions(-) create mode 100644 packages/extension/src/extension/graphView/webview/visibility.ts create mode 100644 packages/extension/tests/playwright-vscode/tooltip-frame-lifecycle.spec.ts create mode 100644 packages/extension/tests/webview/graph/rendering/surface/owned2d/frame/runtime/loop.visibility.integration.test.ts create mode 100644 packages/extension/tests/webview/graph/viewport/shell.tooltipSurface.integration.test.tsx 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 visible = document.visibilityState !== 'hidden'; + 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) { @@ -60,13 +63,13 @@ export function startOwnedGraphFrameLoop( const renderFrame = (timestamp: number): void => { runtime.animationFrameRef.current = null; - if (!active || !visible) return; + if (!active || !isVisible()) return; runtime.frameRequestedRef.current = false; renderOwnedGraphFrame(frameRuntime, canvas, timestamp); }; const scheduleRequestedFrame = (): void => { - if (canScheduleFrame(runtime, active, visible)) { + if (canScheduleFrame(runtime, active, isVisible())) { runtime.animationFrameRef.current = window.requestAnimationFrame(renderFrame); } }; @@ -76,13 +79,16 @@ export function startOwnedGraphFrameLoop( scheduleRequestedFrame(); }; - const handleVisibilityChange = (): void => { - visible = document.visibilityState !== 'hidden'; - if (!visible && runtime.animationFrameRef.current !== null) { + const applyVisibility = (): void => { + if (!isVisible() && runtime.animationFrameRef.current !== null) { window.cancelAnimationFrame(runtime.animationFrameRef.current); runtime.animationFrameRef.current = null; } - if (visible && runtime.frameRequestedRef.current) scheduleRequestedFrame(); + if (isVisible() && runtime.frameRequestedRef.current) scheduleRequestedFrame(); + }; + const handleVisibilityChange = (): void => { + documentVisible = document.visibilityState !== 'hidden'; + applyVisibility(); }; document.addEventListener('visibilitychange', handleVisibilityChange); @@ -96,6 +102,10 @@ export function startOwnedGraphFrameLoop( runtime.requestFrameRef.current(); return { + setHostVisible: (visible) => { + hostVisible = visible; + applyVisibility(); + }, dispose: () => { active = false; document.removeEventListener('visibilitychange', handleVisibilityChange); 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..0fa8cba5e8 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 @@ -155,6 +155,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 +255,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 +271,10 @@ export function OwnedGraphSurface2d(props: Surface2dProps): ReactElement { simulationClockRef, ]); + useEffect(() => { + frameLoopRef.current?.setHostVisible(props.graphViewVisible); + }, [props.graphViewVisible]); + useEffect(() => { clearLinkHover(); reconcileOwnedGraphRuntime(layoutRuntime); @@ -349,6 +358,7 @@ export function OwnedGraphSurface2d(props: Surface2dProps): ReactElement { className="absolute inset-0" data-codegraphy-physics="wasm" data-codegraphy-renderer={rendererStatus} + data-codegraphy-view-visible={String(props.graphViewVisible)} style={{ backgroundColor: props.backgroundColor }} > ; } -export function stopTooltipTracking( +export function clearTooltipAnchorSnapshot( tooltipRectRef: MutableRefObject, ): void { tooltipRectRef.current = null; } -export function startTooltipTracking({ +export function initializeTooltipAnchorSnapshot({ getNodeRect, hoveredNodeRef, tooltipRectRef, @@ -35,7 +35,7 @@ function rectsEqual( && first.radius === second.radius; } -export function updateTooltipTracking({ +export function updateTooltipAnchorSnapshot({ getNodeRect, hoveredNodeRef, setTooltipData, @@ -46,7 +46,7 @@ export function updateTooltipTracking({ const node = hoveredNodeRef.current; const rect = node ? getNodeRect(node) : null; if (!rect) { - stopTooltipTracking(tooltipRectRef); + clearTooltipAnchorSnapshot(tooltipRectRef); return; } if (tooltipRectRef.current && rectsEqual(tooltipRectRef.current, rect)) return; 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 3fcd92d46e..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,7 @@ 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 9a98d57d1a..39b93cd61a 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,7 +109,7 @@ export function useGraphInteractionRuntime({ handleNodeHover, hoveredNodeRef, setTooltipData, - stopTooltipTracking, + clearTooltipAnchor, tooltipData, tooltipTimeoutRef, updateTooltipAnchor, @@ -118,6 +119,7 @@ export function useGraphInteractionRuntime({ fg2dRef: refs.fg2dRef, fileInfoCacheRef, interactionHandlers, + visible: graphViewVisible, legends, pluginHost, postMessage, @@ -181,7 +183,7 @@ export function useGraphInteractionRuntime({ refs, setContextSelection: setLiveContextSelection, setTooltipData, - stopTooltipTracking, + clearTooltipAnchor, tooltipTimeoutRef, }), [ @@ -196,7 +198,7 @@ export function useGraphInteractionRuntime({ refs, setLiveContextSelection, setTooltipData, - stopTooltipTracking, + clearTooltipAnchor, tooltipTimeoutRef, ], ); @@ -265,7 +267,7 @@ export function useGraphInteractionRuntime({ interactionHandlers, marqueeSelection: marqueeRuntime.marqueeSelection, setTooltipData, - stopTooltipTracking, + clearTooltipAnchor, tooltipData, tooltipTimeoutRef, updateTooltipAnchor, 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 1a0fe82052..91b30e3292 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 @@ -16,9 +16,9 @@ import type { GraphTooltipInteractionDependencies } from '../hook'; import { handleTooltipNodeHover } from '../../../tooltip/hover'; import { getTooltipNodeRect } from '../../../tooltip/rect'; import { - startTooltipTracking as beginTooltipTracking, - stopTooltipTracking as endTooltipTracking, - updateTooltipTracking, + clearTooltipAnchorSnapshot, + initializeTooltipAnchorSnapshot, + updateTooltipAnchorSnapshot, } from '../../../tooltip/tracking'; export interface UseTooltipEventsOptions { @@ -34,12 +34,13 @@ export interface UseTooltipEventsOptions { setTooltipData: React.Dispatch>; tooltipRectRef: MutableRefObject; tooltipTimeoutRef: MutableRefObject | null>; + visible: 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; } @@ -54,13 +55,13 @@ export function useTooltipEvents(options: UseTooltipEventsOptions): UseTooltipEv }, node); }, []); - const stopTooltipTracking = useCallback(() => { - endTooltipTracking(optionsRef.current.tooltipRectRef); + const clearTooltipAnchor = useCallback(() => { + clearTooltipAnchorSnapshot(optionsRef.current.tooltipRectRef); }, []); - const startTooltipTracking = useCallback(() => { + const initializeTooltipAnchor = useCallback(() => { const current = optionsRef.current; - beginTooltipTracking({ + initializeTooltipAnchorSnapshot({ getNodeRect: getNodeScreenRect, hoveredNodeRef: current.hoveredNodeRef, tooltipRectRef: current.tooltipRectRef, @@ -72,10 +73,10 @@ export function useTooltipEvents(options: UseTooltipEventsOptions): UseTooltipEv handleTooltipNodeHover(node, { ...current, getNodeRect: getNodeScreenRect, - startTracking: startTooltipTracking, - stopTracking: stopTooltipTracking, + startTracking: initializeTooltipAnchor, + stopTracking: clearTooltipAnchor, }); - }, [getNodeScreenRect, startTooltipTracking, stopTooltipTracking]); + }, [getNodeScreenRect, initializeTooltipAnchor, clearTooltipAnchor]); const handleMouseLeave = useCallback(() => { handleNodeHover(null); @@ -83,7 +84,7 @@ export function useTooltipEvents(options: UseTooltipEventsOptions): UseTooltipEv const updateTooltipAnchor = useCallback(() => { const current = optionsRef.current; - updateTooltipTracking({ + updateTooltipAnchorSnapshot({ getNodeRect: getNodeScreenRect, hoveredNodeRef: current.hoveredNodeRef, setTooltipData: current.setTooltipData, @@ -106,16 +107,20 @@ export function useTooltipEvents(options: UseTooltipEventsOptions): UseTooltipEv clearTimeout(current.tooltipTimeoutRef.current); current.tooltipTimeoutRef.current = null; } - endTooltipTracking(current.tooltipRectRef); + clearTooltipAnchorSnapshot(current.tooltipRectRef); }; }, [handleMouseLeave, handleNodeHover], ); + useEffect(() => { + if (!options.visible) handleNodeHover(null); + }, [handleNodeHover, options.visible]); + return { handleMouseLeave, handleNodeHover, - stopTooltipTracking, + clearTooltipAnchor, updateTooltipAnchor, }; } 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 d5aa5df343..905cdf894b 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; + visible: boolean; } export interface UseGraphTooltipResult { @@ -31,7 +32,7 @@ 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; @@ -46,6 +47,7 @@ export function useGraphTooltip({ legends, pluginHost, postMessage, + visible, }: UseGraphTooltipOptions): UseGraphTooltipResult { const { hoveredNodeRef, @@ -57,7 +59,7 @@ export function useGraphTooltip({ const { handleMouseLeave, handleNodeHover, - stopTooltipTracking, + clearTooltipAnchor, updateTooltipAnchor, } = useTooltipEvents({ containerRef, @@ -72,6 +74,7 @@ export function useGraphTooltip({ setTooltipData, tooltipRectRef, tooltipTimeoutRef, + visible, }); return { @@ -79,7 +82,7 @@ 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/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..2fd3bbbd4d --- /dev/null +++ b/packages/extension/tests/playwright-vscode/tooltip-frame-lifecycle.spec.ts @@ -0,0 +1,186 @@ +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'; + +test.use({ video: captureProof ? 'on' : 'retain-on-failure' }); + +test('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 resetFrameProbe(frame); + await vscode.page.keyboard.press('Meta+Shift+E'); + await expect(frame.locator('[data-codegraphy-view-visible="false"]')).toHaveCount(1, { + timeout: 10_000, + }); + expect(await readDocumentVisibility(frame)).toBe('visible'); + const hiddenFrameRequests = await observeFrameRequests(frame, 1_500); + expect(hiddenFrameRequests).toBe(0); + expect(await readGraphFps(frame)).toBeNull(); + } 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.hidden = true; + document.body.append(output); + 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: { + 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 graph RAF requests: ${currentEvidence.stationaryFrameRequests}`, + 'Physics/render FPS: idle', + ].join('\n'); + }, evidence); +} 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 68f41ccc13..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; @@ -138,11 +139,11 @@ describe('owned graph frame loop', () => { expect(requestAnimationFrame).toHaveBeenCalledOnce(); }); - it('performs zero graph frames and zero physics work while the hovered graph is hidden', () => { + it('cancels a running retained view and performs zero frames while host-hidden', () => { vi.mocked(renderOwnedGraphFrame).mockClear(); Object.defineProperty(document, 'visibilityState', { configurable: true, - value: 'hidden', + value: 'visible', }); const requestAnimationFrame = vi.fn(() => 23); vi.stubGlobal('requestAnimationFrame', requestAnimationFrame); @@ -156,6 +157,7 @@ describe('owned graph frame loop', () => { gpuRendererRef: { current: null }, hoveredNodeRef: { current: { id: 'src/app.ts' } }, layoutRef: { current: {} }, + propsRef: { current: { graphViewVisible: true } }, rendererOperationalRef: { current: true }, requestFrameRef: { current: () => {} }, } as unknown as OwnedGraphFrameLoopRuntime; @@ -165,18 +167,16 @@ describe('owned graph frame loop', () => { document.createElement('canvas'), { current: undefined }, ); + expect(requestAnimationFrame).toHaveBeenCalledOnce(); + loop.setHostVisible(false); runtime.requestFrameRef.current(); expect(runtime.frameRequestedRef.current).toBe(true); - expect(requestAnimationFrame).not.toHaveBeenCalled(); + expect(requestAnimationFrame).toHaveBeenCalledOnce(); expect(renderOwnedGraphFrame).not.toHaveBeenCalled(); - Object.defineProperty(document, 'visibilityState', { - configurable: true, - value: 'visible', - }); - document.dispatchEvent(new Event('visibilitychange')); - expect(requestAnimationFrame).toHaveBeenCalledOnce(); + 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/behavior/performance.test.tsx b/packages/extension/tests/webview/graph/rendering/surface/owned2d/view/behavior/performance.test.tsx index e9b81d3d29..c33c60b216 100644 --- a/packages/extension/tests/webview/graph/rendering/surface/owned2d/view/behavior/performance.test.tsx +++ b/packages/extension/tests/webview/graph/rendering/surface/owned2d/view/behavior/performance.test.tsx @@ -20,8 +20,6 @@ vi.mock('@codegraphy-dev/graph-renderer', async importOriginal => ({ })); import { OwnedGraphSurface2d } from '../../../../../../../../src/webview/components/graph/rendering/surface/owned2d/view/surface/render'; -import { startTooltipTracking } from '../../../../../../../../src/webview/components/graph/runtime/tooltip/tracking'; -import type { FGNode } from '../../../../../../../../src/webview/components/graph/model/build'; describe('OwnedGraphSurface2d performance presentation', () => { afterEach(() => { @@ -37,74 +35,6 @@ describe('OwnedGraphSurface2d performance presentation', () => { vi.stubGlobal('PointerEvent', MouseEvent); }); - it('submits zero repeated graph frames for a settled stationary Node tooltip', async () => { - const frames: FrameRequestCallback[] = []; - vi.mocked(requestAnimationFrame).mockImplementation(callback => { - frames.push(callback); - return frames.length; - }); - const context = { - clearRect: vi.fn(), - restore: vi.fn(), - save: vi.fn(), - scale: vi.fn(), - setTransform: vi.fn(), - translate: vi.fn(), - } as unknown as CanvasRenderingContext2D; - rendererHarness.render.mockReturnValue(1); - rendererHarness.create.mockResolvedValue({ - canRender: () => true, - dispose: rendererHarness.dispose, - render: rendererHarness.render, - setSecondarySurface: rendererHarness.setSecondarySurface, - }); - const props = createDefaultSurfaceProps(); - props.showMinimap = false; - const rendered = render(); - const overlay = rendered.container.querySelectorAll('canvas')[1]; - Object.defineProperty(overlay, 'getContext', { value: () => context }); - await waitFor(() => { - expect(rendered.container.firstElementChild).toHaveAttribute( - 'data-codegraphy-renderer', - 'webgpu', - ); - }); - - let timestamp = 0; - let drainedFrames = 0; - while (frames.length > 0 && drainedFrames < 100) { - const frame = frames.shift(); - act(() => frame?.(timestamp)); - timestamp += 1000 / 60; - drainedFrames += 1; - } - expect(drainedFrames).toBeLessThan(100); - expect(frames).toHaveLength(0); - const settledSubmissions = rendererHarness.render.mock.calls.length; - const settledFrameRequests = vi.mocked(requestAnimationFrame).mock.calls.length; - - const hoveredNode: FGNode = { - baseOpacity: 1, - borderColor: '#000', - borderWidth: 1, - color: '#fff', - id: 'src/app.ts', - isFavorite: false, - isPinned: false, - label: 'app.ts', - size: 12, - }; - startTooltipTracking({ - getNodeRect: () => ({ x: 120, y: 80, radius: 12 }), - hoveredNodeRef: { current: hoveredNode }, - tooltipRectRef: { current: null }, - }); - - expect(rendererHarness.render).toHaveBeenCalledTimes(settledSubmissions); - expect(requestAnimationFrame).toHaveBeenCalledTimes(settledFrameRequests); - expect(frames).toHaveLength(0); - }); - it('shows actual rendered FPS separately from CPU frame cost while tracking remains always on', async () => { const frames: FrameRequestCallback[] = []; vi.mocked(requestAnimationFrame).mockImplementation(callback => { 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/tracking.test.ts b/packages/extension/tests/webview/graph/runtime/tooltip/tracking.test.ts index 5eb5f51c78..a0864835d9 100644 --- a/packages/extension/tests/webview/graph/runtime/tooltip/tracking.test.ts +++ b/packages/extension/tests/webview/graph/runtime/tooltip/tracking.test.ts @@ -3,16 +3,16 @@ 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, - updateTooltipTracking, + clearTooltipAnchorSnapshot, + initializeTooltipAnchorSnapshot, + updateTooltipAnchorSnapshot, } from '../../../../../src/webview/components/graph/runtime/tooltip/tracking'; describe('tooltipTracking', () => { it('clears the tracked anchor', () => { const tooltipRectRef = { current: { x: 10, y: 20, radius: 30 } }; - stopTooltipTracking(tooltipRectRef); + clearTooltipAnchorSnapshot(tooltipRectRef); expect(tooltipRectRef.current).toBeNull(); }); @@ -22,7 +22,7 @@ describe('tooltipTracking', () => { vi.stubGlobal('requestAnimationFrame', requestAnimationFrameSpy); const tooltipRectRef = { current: null }; - startTooltipTracking({ + initializeTooltipAnchorSnapshot({ getNodeRect: () => ({ x: 10, y: 20, radius: 30 }), hoveredNodeRef: { current: { id: 'node' } as FGNode }, tooltipRectRef, @@ -50,7 +50,7 @@ describe('tooltipTracking', () => { ) as Dispatch>; const tooltipRectRef = { current: { x: 1, y: 2, radius: 3 } }; - updateTooltipTracking({ + updateTooltipAnchorSnapshot({ getNodeRect: () => ({ x: 10, y: 20, radius: 30 }), hoveredNodeRef: { current: { id: 'node' } as FGNode }, setTooltipData, @@ -73,8 +73,8 @@ describe('tooltipTracking', () => { tooltipRectRef: { current: rect }, }; - updateTooltipTracking(options); - updateTooltipTracking(options); + updateTooltipAnchorSnapshot(options); + updateTooltipAnchorSnapshot(options); expect(getNodeRect).toHaveBeenCalledTimes(2); expect(setTooltipData).not.toHaveBeenCalled(); @@ -84,7 +84,7 @@ describe('tooltipTracking', () => { const setTooltipData = vi.fn(); const tooltipRectRef = { current: { x: 10, y: 20, radius: 30 } }; - updateTooltipTracking({ + updateTooltipAnchorSnapshot({ getNodeRect: vi.fn(), hoveredNodeRef: { current: null }, setTooltipData, @@ -93,7 +93,7 @@ describe('tooltipTracking', () => { expect(tooltipRectRef.current).toBeNull(); tooltipRectRef.current = { x: 10, y: 20, radius: 30 }; - updateTooltipTracking({ + updateTooltipAnchorSnapshot({ getNodeRect: () => null, hoveredNodeRef: { current: { id: 'node' } as FGNode }, setTooltipData, 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 d3f1fa2976..71161a7362 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 @@ -68,6 +68,7 @@ describe('graph/runtime/useTooltipEvents', () => { setTooltipData, tooltipRectRef, tooltipTimeoutRef: { current: null }, + visible: true, })); act(() => { @@ -97,6 +98,7 @@ describe('graph/runtime/useTooltipEvents', () => { setTooltipData, tooltipRectRef, tooltipTimeoutRef: { current: null }, + visible: true, })); act(() => { @@ -134,6 +136,7 @@ describe('graph/runtime/useTooltipEvents', () => { setTooltipData: vi.fn(), tooltipRectRef, tooltipTimeoutRef: { current: null }, + visible: true, })); unmount(); @@ -141,6 +144,31 @@ describe('graph/runtime/useTooltipEvents', () => { 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 }, + visible: true, + }; + const { rerender } = renderHook(useTooltipEvents, { initialProps: options }); + + rerender({ ...options, visible: 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', () => { vi.useFakeTimers(); const firstSetTooltipData = vi.fn(); @@ -163,6 +191,7 @@ describe('graph/runtime/useTooltipEvents', () => { setTooltipData: firstSetTooltipData, tooltipRectRef: { current: null }, tooltipTimeoutRef: { current: null }, + visible: true, }; const { result, rerender } = renderHook(useTooltipEvents, { initialProps, 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..abda74dfde 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(), + visible: 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 61b39a95fb..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 }, 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..51b41f05a5 --- /dev/null +++ b/packages/extension/tests/webview/graph/viewport/shell.tooltipSurface.integration.test.tsx @@ -0,0 +1,74 @@ +import React from 'react'; +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; +import { screen } from '@testing-library/react'; + +const surfaceHarness = vi.hoisted(() => ({ renders: vi.fn() })); + +vi.mock('../../../../src/webview/components/graph/rendering/surface/owned2d/view/surface/render', () => ({ + OwnedGraphSurface2d: () => { + surfaceHarness.renders(); + requestAnimationFrame(() => undefined); + return
; + }, +})); + +import { + createInteractions, + renderActualViewport, + renderGraphViewportShell, + resetShellHarness, +} from './shellFixture'; + +describe('GraphViewportShell tooltip surface boundary', () => { + afterEach(() => vi.unstubAllGlobals()); + + beforeEach(() => { + resetShellHarness(); + renderActualViewport(); + surfaceHarness.renders.mockReset(); + vi.stubGlobal('requestAnimationFrame', vi.fn(() => 1)); + }); + + it('submits zero repeated graph frames for a settled stationary Node tooltip', () => { + const interactions = createInteractions(); + const rendered = renderGraphViewportShell({ interactions }); + expect(surfaceHarness.renders).toHaveBeenCalledOnce(); + expect(requestAnimationFrame).toHaveBeenCalledOnce(); + expect(screen.queryByText('src/app.ts')).not.toBeInTheDocument(); + + rendered.rerenderShell({ + interactions: { + ...interactions, + tooltipData: { + info: null, + nodeRect: { x: 50, y: 60, radius: 12 }, + path: 'src/app.ts', + pluginActions: [], + pluginSections: [], + visible: true, + }, + }, + }); + + expect(screen.getByText('src/app.ts')).toBeVisible(); + expect(surfaceHarness.renders).toHaveBeenCalledOnce(); + expect(requestAnimationFrame).toHaveBeenCalledOnce(); + + rendered.rerenderShell({ + interactions: { + ...interactions, + tooltipData: { + info: null, + nodeRect: { x: 50, y: 60, radius: 12 }, + path: 'src/app.ts', + pluginActions: [], + pluginSections: [], + visible: true, + }, + }, + }); + + 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 b8e3e53b25..00aad2abe7 100644 --- a/packages/extension/tests/webview/graph/viewport/shellFixture.tsx +++ b/packages/extension/tests/webview/graph/viewport/shellFixture.tsx @@ -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,7 +184,7 @@ 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; @@ -207,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, @@ -221,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, @@ -237,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 }, @@ -256,6 +268,10 @@ export function resetShellHarness(): void { }); } +export function renderActualViewport(): void { + shellHarness.renderActualViewport = true; +} + 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' }, From 715ebdc68566eeaf1e5afc9abd1c7584b26c3aba Mon Sep 17 00:00:00 2001 From: joesobo Date: Thu, 6 Aug 2026 10:50:20 -0700 Subject: [PATCH 3/6] test(extension): prove tooltip frame lifecycle in VS Code --- .../tooltip-frame-lifecycle.spec.ts | 22 ++++++++++++++++++- 1 file changed, 21 insertions(+), 1 deletion(-) diff --git a/packages/extension/tests/playwright-vscode/tooltip-frame-lifecycle.spec.ts b/packages/extension/tests/playwright-vscode/tooltip-frame-lifecycle.spec.ts index 2fd3bbbd4d..9826f7a6ee 100644 --- a/packages/extension/tests/playwright-vscode/tooltip-frame-lifecycle.spec.ts +++ b/packages/extension/tests/playwright-vscode/tooltip-frame-lifecycle.spec.ts @@ -54,15 +54,31 @@ test('stationary and hidden Node tooltips stop graph frame work in an Extension path: screenshotPath, }); - await resetFrameProbe(frame); await vscode.page.keyboard.press('Meta+Shift+E'); await expect(frame.locator('[data-codegraphy-view-visible="false"]')).toHaveCount(1, { 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(frame.locator('[data-codegraphy-view-visible="true"]')).toHaveCount(1, { + 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 }); @@ -157,6 +173,7 @@ async function readDocumentVisibility(frame: Frame): Promise { await frame.evaluate((currentEvidence) => { @@ -181,6 +198,9 @@ async function showFrameEvidence(frame: Frame, evidence: { 'Stationary Node tooltip', `1.5 s graph RAF requests: ${currentEvidence.stationaryFrameRequests}`, 'Physics/render FPS: idle', + ...(currentEvidence.hiddenFrameRequests === undefined + ? [] + : [`Hidden Graph View RAF requests: ${currentEvidence.hiddenFrameRequests}`]), ].join('\n'); }, evidence); } From 0b16ed40bda5b369253fbda0b7d4932576da0f09 Mon Sep 17 00:00:00 2001 From: joesobo Date: Thu, 6 Aug 2026 10:52:50 -0700 Subject: [PATCH 4/6] fix(extension): synchronize tooltip handlers after render --- .../components/graph/runtime/use/tooltip/events/handlers.ts | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) 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 91b30e3292..a05509236e 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,6 +1,7 @@ import { useCallback, useEffect, + useLayoutEffect, useRef, type MutableRefObject, } from 'react'; @@ -46,7 +47,9 @@ export interface UseTooltipEventsResult { export function useTooltipEvents(options: UseTooltipEventsOptions): UseTooltipEventsResult { const optionsRef = useRef(options); - optionsRef.current = options; + useLayoutEffect(() => { + optionsRef.current = options; + }, [options]); const getNodeScreenRect = useCallback((node: FGNode) => { const current = optionsRef.current; return getTooltipNodeRect({ From ba0f553da985457f7edc9c1fe4357fc06c9f9a7d Mon Sep 17 00:00:00 2001 From: joesobo Date: Fri, 7 Aug 2026 08:05:40 -0700 Subject: [PATCH 5/6] test(extension): close tooltip lifecycle review gaps --- .../surface/owned2d/view/surface/render.tsx | 4 +- .../components/graph/runtime/tooltip/hover.ts | 16 +-- .../graph/runtime/tooltip/schedule.ts | 4 +- .../graph/runtime/use/interaction/hook.ts | 2 +- .../runtime/use/tooltip/events/handlers.ts | 10 +- .../graph/runtime/use/tooltip/hook.ts | 6 +- .../tooltip-frame-lifecycle.spec.ts | 19 +++- .../tests/playwrightVscodeConfig.test.ts | 9 ++ .../graph/runtime/tooltip/hover.test.ts | 56 +++++----- .../graph/runtime/tooltip/tracking.test.ts | 6 +- .../use/tooltip/events/handlers.test.tsx | 12 +-- .../graph/runtime/use/tooltip/hook.test.tsx | 2 +- .../shell.tooltipSurface.integration.test.tsx | 102 ++++++++++++------ .../webview/graph/viewport/shellFixture.tsx | 8 +- 14 files changed, 156 insertions(+), 100 deletions(-) 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 0fa8cba5e8..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, @@ -271,7 +272,7 @@ export function OwnedGraphSurface2d(props: Surface2dProps): ReactElement { simulationClockRef, ]); - useEffect(() => { + useLayoutEffect(() => { frameLoopRef.current?.setHostVisible(props.graphViewVisible); }, [props.graphViewVisible]); @@ -358,7 +359,6 @@ export function OwnedGraphSurface2d(props: Surface2dProps): ReactElement { className="absolute inset-0" data-codegraphy-physics="wasm" data-codegraphy-renderer={rendererStatus} - data-codegraphy-view-visible={String(props.graphViewVisible)} style={{ backgroundColor: props.backgroundColor }} > >; - 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>; tooltipRectRef: MutableRefObject; tooltipTimeoutRef: MutableRefObject | null>; - visible: boolean; + graphViewVisible: boolean; } export interface UseTooltipEventsResult { @@ -76,8 +76,8 @@ export function useTooltipEvents(options: UseTooltipEventsOptions): UseTooltipEv handleTooltipNodeHover(node, { ...current, getNodeRect: getNodeScreenRect, - startTracking: initializeTooltipAnchor, - stopTracking: clearTooltipAnchor, + initializeAnchorSnapshot: initializeTooltipAnchor, + clearAnchorSnapshot: clearTooltipAnchor, }); }, [getNodeScreenRect, initializeTooltipAnchor, clearTooltipAnchor]); @@ -117,8 +117,8 @@ export function useTooltipEvents(options: UseTooltipEventsOptions): UseTooltipEv ); useEffect(() => { - if (!options.visible) handleNodeHover(null); - }, [handleNodeHover, options.visible]); + if (!options.graphViewVisible) handleNodeHover(null); + }, [handleNodeHover, options.graphViewVisible]); return { handleMouseLeave, 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 905cdf894b..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,7 +24,7 @@ export interface UseGraphTooltipOptions { legends?: readonly IGroup[]; pluginHost?: WebviewPluginHost; postMessage: (this: void, message: WebviewToExtensionMessage) => void; - visible: boolean; + graphViewVisible: boolean; } export interface UseGraphTooltipResult { @@ -47,7 +47,7 @@ export function useGraphTooltip({ legends, pluginHost, postMessage, - visible, + graphViewVisible, }: UseGraphTooltipOptions): UseGraphTooltipResult { const { hoveredNodeRef, @@ -74,7 +74,7 @@ export function useGraphTooltip({ setTooltipData, tooltipRectRef, tooltipTimeoutRef, - visible, + graphViewVisible, }); return { diff --git a/packages/extension/tests/playwright-vscode/tooltip-frame-lifecycle.spec.ts b/packages/extension/tests/playwright-vscode/tooltip-frame-lifecycle.spec.ts index 9826f7a6ee..aa0374ec90 100644 --- a/packages/extension/tests/playwright-vscode/tooltip-frame-lifecycle.spec.ts +++ b/packages/extension/tests/playwright-vscode/tooltip-frame-lifecycle.spec.ts @@ -18,7 +18,7 @@ const captureProof = process.env.CODEGRAPHY_CAPTURE_TOOLTIP_PROOF === '1'; test.use({ video: captureProof ? 'on' : 'retain-on-failure' }); -test('stationary and hidden Node tooltips stop graph frame work in an Extension Development Host', async ({}, testInfo) => { +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); @@ -55,7 +55,7 @@ test('stationary and hidden Node tooltips stop graph frame work in an Extension }); await vscode.page.keyboard.press('Meta+Shift+E'); - await expect(frame.locator('[data-codegraphy-view-visible="false"]')).toHaveCount(1, { + await expect(frameProbe(frame)).toHaveAttribute('data-graph-view-visible', 'false', { timeout: 10_000, }); expect(await readDocumentVisibility(frame)).toBe('visible'); @@ -67,7 +67,7 @@ test('stationary and hidden Node tooltips stop graph frame work in an Extension expect(await readGraphFps(frame)).toBeNull(); await openGraphView(vscode.page); - await expect(frame.locator('[data-codegraphy-view-visible="true"]')).toHaveCount(1, { + await expect(frameProbe(frame)).toHaveAttribute('data-graph-view-visible', 'true', { timeout: 10_000, }); await graphNode(frame, 'src/index.ts').dispatchEvent('mouseover', { bubbles: true }); @@ -109,8 +109,17 @@ async function installFrameProbe(frame: Frame): Promise { 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( @@ -196,11 +205,11 @@ async function showFrameEvidence(frame: Frame, evidence: { ].join(';'); output.textContent = [ 'Stationary Node tooltip', - `1.5 s graph RAF requests: ${currentEvidence.stationaryFrameRequests}`, + `1.5 s webview RAF requests: ${currentEvidence.stationaryFrameRequests}`, 'Physics/render FPS: idle', ...(currentEvidence.hiddenFrameRequests === undefined ? [] - : [`Hidden Graph View RAF requests: ${currentEvidence.hiddenFrameRequests}`]), + : [`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/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 a0864835d9..ac881e987b 100644 --- a/packages/extension/tests/webview/graph/runtime/tooltip/tracking.test.ts +++ b/packages/extension/tests/webview/graph/runtime/tooltip/tracking.test.ts @@ -8,7 +8,7 @@ import { updateTooltipAnchorSnapshot, } from '../../../../../src/webview/components/graph/runtime/tooltip/tracking'; -describe('tooltipTracking', () => { +describe('tooltip anchor snapshots', () => { it('clears the tracked anchor', () => { const tooltipRectRef = { current: { x: 10, y: 20, radius: 30 } }; @@ -17,7 +17,7 @@ describe('tooltipTracking', () => { expect(tooltipRectRef.current).toBeNull(); }); - it('starts tracking without owning an animation frame', () => { + it('initializes the anchor snapshot without owning an animation frame', () => { const requestAnimationFrameSpy = vi.fn(); vi.stubGlobal('requestAnimationFrame', requestAnimationFrameSpy); const tooltipRectRef = { current: null }; @@ -32,7 +32,7 @@ describe('tooltipTracking', () => { expect(requestAnimationFrameSpy).not.toHaveBeenCalled(); }); - it('updates visible tooltip state only after its tracked anchor changes', () => { + it('updates visible tooltip state only after its anchor changes', () => { const setTooltipData = vi.fn( (update: SetStateAction) => { const nextState = 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 71161a7362..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 @@ -68,7 +68,7 @@ describe('graph/runtime/useTooltipEvents', () => { setTooltipData, tooltipRectRef, tooltipTimeoutRef: { current: null }, - visible: true, + graphViewVisible: true, })); act(() => { @@ -98,7 +98,7 @@ describe('graph/runtime/useTooltipEvents', () => { setTooltipData, tooltipRectRef, tooltipTimeoutRef: { current: null }, - visible: true, + graphViewVisible: true, })); act(() => { @@ -136,7 +136,7 @@ describe('graph/runtime/useTooltipEvents', () => { setTooltipData: vi.fn(), tooltipRectRef, tooltipTimeoutRef: { current: null }, - visible: true, + graphViewVisible: true, })); unmount(); @@ -158,11 +158,11 @@ describe('graph/runtime/useTooltipEvents', () => { setTooltipData: vi.fn(), tooltipRectRef, tooltipTimeoutRef: { current: null }, - visible: true, + graphViewVisible: true, }; const { rerender } = renderHook(useTooltipEvents, { initialProps: options }); - rerender({ ...options, visible: false }); + rerender({ ...options, graphViewVisible: false }); expect(hoveredNodeRef.current).toBeNull(); expect(tooltipRectRef.current).toBeNull(); @@ -191,7 +191,7 @@ describe('graph/runtime/useTooltipEvents', () => { setTooltipData: firstSetTooltipData, tooltipRectRef: { current: null }, tooltipTimeoutRef: { current: null }, - visible: true, + graphViewVisible: true, }; const { result, rerender } = renderHook(useTooltipEvents, { initialProps, 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 abda74dfde..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,7 +36,7 @@ describe('graph/runtime/useGraphTooltip', () => { setGraphCursor, }, postMessage: vi.fn(), - visible: true, + graphViewVisible: true, })); act(() => { 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 index 51b41f05a5..60f3564996 100644 --- a/packages/extension/tests/webview/graph/viewport/shell.tooltipSurface.integration.test.tsx +++ b/packages/extension/tests/webview/graph/viewport/shell.tooltipSurface.integration.test.tsx @@ -1,72 +1,104 @@ import React from 'react'; import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; -import { screen } from '@testing-library/react'; +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(() => ({ renders: vi.fn() })); +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: () => { + OwnedGraphSurface2d: (props: { onRenderFramePost(ctx: CanvasRenderingContext2D, globalScale: number): void }) => { + surfaceHarness.renderFramePost = props.onRenderFramePost; surfaceHarness.renders(); requestAnimationFrame(() => undefined); - return
; + return
; }, })); import { + createCallbacks, + createGraphData, + createGraphViewportShellElement, + createGraphState, createInteractions, + createViewState, renderActualViewport, - renderGraphViewportShell, resetShellHarness, } from './shellFixture'; describe('GraphViewportShell tooltip surface boundary', () => { - afterEach(() => vi.unstubAllGlobals()); + 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 for a settled stationary Node tooltip', () => { - const interactions = createInteractions(); - const rendered = renderGraphViewportShell({ interactions }); + 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(); - rendered.rerenderShell({ - interactions: { - ...interactions, - tooltipData: { - info: null, - nodeRect: { x: 50, y: 60, radius: 12 }, - path: 'src/app.ts', - pluginActions: [], - pluginSections: [], - visible: true, - }, - }, - }); + act(() => { + handleNodeHover?.(graphState.renderer.graphData.nodes[0]); + vi.advanceTimersByTime(500); + }); expect(screen.getByText('src/app.ts')).toBeVisible(); expect(surfaceHarness.renders).toHaveBeenCalledOnce(); expect(requestAnimationFrame).toHaveBeenCalledOnce(); - rendered.rerenderShell({ - interactions: { - ...interactions, - tooltipData: { - info: null, - nodeRect: { x: 50, y: 60, radius: 12 }, - path: 'src/app.ts', - pluginActions: [], - pluginSections: [], - visible: true, - }, - }, - }); + 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 00aad2abe7..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'; @@ -272,6 +272,12 @@ export function renderActualViewport(): void { shellHarness.renderActualViewport = true; } +export function createGraphViewportShellElement( + props: ComponentProps, +): ReactElement { + return ; +} + export function renderGraphViewportShell(options: { callbacks?: ReturnType; graphState?: GraphRuntime; From 3507c9280eb866f21a0e8aef97379f0aeea75451 Mon Sep 17 00:00:00 2001 From: joesobo Date: Fri, 7 Aug 2026 08:20:59 -0700 Subject: [PATCH 6/6] test(extension): use platform explorer shortcut --- .../tests/playwright-vscode/tooltip-frame-lifecycle.spec.ts | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/packages/extension/tests/playwright-vscode/tooltip-frame-lifecycle.spec.ts b/packages/extension/tests/playwright-vscode/tooltip-frame-lifecycle.spec.ts index aa0374ec90..33323f1a38 100644 --- a/packages/extension/tests/playwright-vscode/tooltip-frame-lifecycle.spec.ts +++ b/packages/extension/tests/playwright-vscode/tooltip-frame-lifecycle.spec.ts @@ -15,6 +15,7 @@ import { } 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' }); @@ -54,7 +55,7 @@ test('Hover performance: stationary and hidden Node tooltips stop graph frame wo path: screenshotPath, }); - await vscode.page.keyboard.press('Meta+Shift+E'); + await vscode.page.keyboard.press(explorerShortcut); await expect(frameProbe(frame)).toHaveAttribute('data-graph-view-visible', 'false', { timeout: 10_000, });