diff --git a/ui/packages/shared/profile/src/ProfileFlameGraph/FlameGraphArrow/FlameGraphNodes.tsx b/ui/packages/shared/profile/src/ProfileFlameGraph/FlameGraphArrow/FlameGraphNodes.tsx index 57b9aa758d4..84709d6a241 100644 --- a/ui/packages/shared/profile/src/ProfileFlameGraph/FlameGraphArrow/FlameGraphNodes.tsx +++ b/ui/packages/shared/profile/src/ProfileFlameGraph/FlameGraphArrow/FlameGraphNodes.tsx @@ -11,7 +11,7 @@ // See the License for the specific language governing permissions and // limitations under the License. -import React, {useMemo} from 'react'; +import React, {useCallback, useMemo} from 'react'; import {Table} from 'apache-arrow'; import cx from 'classnames'; @@ -101,36 +101,52 @@ export const FlameNode = React.memo( effectiveDepth, tooltipId = 'default', }: FlameNodeProps): React.JSX.Element { - // get the columns to read from - const mappingColumn = table.getChild(FIELD_MAPPING_FILE); - const functionNameColumn = table.getChild(FIELD_FUNCTION_NAME); - const cumulativeColumn = table.getChild(FIELD_CUMULATIVE); - const depthColumn = table.getChild(FIELD_DEPTH); - const diffColumn = table.getChild(FIELD_DIFF); - const filenameColumn = table.getChild(FIELD_FUNCTION_FILE_NAME); - const valueOffsetColumn = table.getChild(FIELD_VALUE_OFFSET); - const tsColumn = table.getChild(FIELD_TIMESTAMP); + // Memoize column references - only changes when table changes + const columns = useMemo( + () => ({ + mapping: table.getChild(FIELD_MAPPING_FILE), + functionName: table.getChild(FIELD_FUNCTION_NAME), + cumulative: table.getChild(FIELD_CUMULATIVE), + depth: table.getChild(FIELD_DEPTH), + diff: table.getChild(FIELD_DIFF), + filename: table.getChild(FIELD_FUNCTION_FILE_NAME), + valueOffset: table.getChild(FIELD_VALUE_OFFSET), + ts: table.getChild(FIELD_TIMESTAMP), + }), + [table] + ); // get the actual values from the columns const binaries = useAppSelector(selectBinaries); - const mappingFile: string | null = arrowToString(mappingColumn?.get(row)); - const functionName: string | null = arrowToString(functionNameColumn?.get(row)); - const cumulative = cumulativeColumn?.get(row) != null ? BigInt(cumulativeColumn?.get(row)) : 0n; - const diff: bigint | null = diffColumn?.get(row) != null ? BigInt(diffColumn?.get(row)) : null; - const filename: string | null = arrowToString(filenameColumn?.get(row)); - const depth: number = depthColumn?.get(row) ?? 0; - - const valueOffset: bigint = - valueOffsetColumn?.get(row) !== null && valueOffsetColumn?.get(row) !== undefined - ? BigInt(valueOffsetColumn?.get(row)) - : 0n; + // Memoize row data extraction - only changes when table or row changes + const rowData = useMemo(() => { + const mappingFile: string | null = arrowToString(columns.mapping?.get(row)); + const functionName: string | null = arrowToString(columns.functionName?.get(row)); + const cumulative = + columns.cumulative?.get(row) != null ? BigInt(columns.cumulative?.get(row)) : 0n; + const diff: bigint | null = + columns.diff?.get(row) != null ? BigInt(columns.diff?.get(row)) : null; + const filename: string | null = arrowToString(columns.filename?.get(row)); + const depth: number = columns.depth?.get(row) ?? 0; + const valueOffset: bigint = + columns.valueOffset?.get(row) !== null && columns.valueOffset?.get(row) !== undefined + ? BigInt(columns.valueOffset?.get(row)) + : 0n; + + return {mappingFile, functionName, cumulative, diff, filename, depth, valueOffset}; + }, [columns, row]); + + const {mappingFile, functionName, cumulative, diff, filename, depth, valueOffset} = rowData; const colorAttribute = colorBy === 'filename' ? filename : colorBy === 'binary' ? mappingFile : null; - const hoveringName = - hoveringRow !== undefined ? arrowToString(functionNameColumn?.get(hoveringRow)) : ''; + // Memoize hovering name lookup + const hoveringName = useMemo(() => { + return hoveringRow !== undefined ? arrowToString(columns.functionName?.get(hoveringRow)) : ''; + }, [columns.functionName, hoveringRow]); + const shouldBeHighlighted = functionName != null && hoveringName != null && functionName === hoveringName; @@ -147,18 +163,59 @@ export const FlameNode = React.memo( return row === 0 ? 'root' : nodeLabel(table, row, binaries.length > 1); }, [table, row, binaries]); + // Memoize selection data - only changes when selectedRow changes + const selectionData = useMemo(() => { + const selectionOffset = + columns.valueOffset?.get(selectedRow) !== null && + columns.valueOffset?.get(selectedRow) !== undefined + ? BigInt(columns.valueOffset?.get(selectedRow)) + : 0n; + const selectionCumulative = + columns.cumulative?.get(selectedRow) !== null + ? BigInt(columns.cumulative?.get(selectedRow)) + : 0n; + const selectedDepth = columns.depth?.get(selectedRow); + const total = columns.cumulative?.get(selectedRow); + return {selectionOffset, selectionCumulative, selectedDepth, total}; + }, [columns, selectedRow]); + + const {selectionOffset, selectionCumulative, selectedDepth, total} = selectionData; + + // Memoize tsBounds - only changes when profileSource changes + const tsBounds = useMemo(() => boundsFromProfileSource(profileSource), [profileSource]); + + // Memoize event handlers + const onMouseEnter = useCallback((): void => { + setHoveringRow(row); + window.dispatchEvent( + new CustomEvent(`flame-tooltip-update-${tooltipId}`, { + detail: {row}, + }) + ); + }, [setHoveringRow, row, tooltipId]); + + const onMouseLeave = useCallback((): void => { + setHoveringRow(undefined); + window.dispatchEvent( + new CustomEvent(`flame-tooltip-update-${tooltipId}`, { + detail: {row: null}, + }) + ); + }, [setHoveringRow, tooltipId]); + + const handleContextMenu = useCallback( + (e: React.MouseEvent): void => { + onContextMenu(e, row); + }, + [onContextMenu, row] + ); + + // Early returns - all hooks must be called before this point // Hide frames beyond effective depth limit if (effectiveDepth !== undefined && depth > effectiveDepth) { return <>; } - const selectionOffset = - valueOffsetColumn?.get(selectedRow) !== null && - valueOffsetColumn?.get(selectedRow) !== undefined - ? BigInt(valueOffsetColumn?.get(selectedRow)) - : 0n; - const selectionCumulative = - cumulativeColumn?.get(selectedRow) !== null ? BigInt(cumulativeColumn?.get(selectedRow)) : 0n; if ( valueOffset + cumulative <= selectionOffset || valueOffset >= selectionOffset + selectionCumulative @@ -173,8 +230,6 @@ export const FlameNode = React.memo( } // Cumulative can be larger than total when a selection is made. All parents of the selection are likely larger, but we want to only show them as 100% in the graph. - const tsBounds = boundsFromProfileSource(profileSource); - const total = cumulativeColumn?.get(selectedRow); const totalRatio = cumulative > total ? 1 : Number(cumulative) / Number(total); const width: number = isFlameChart ? (Number(cumulative) / (Number(tsBounds[1]) - Number(tsBounds[0]))) * totalWidth @@ -184,35 +239,12 @@ export const FlameNode = React.memo( return <>; } - const selectedDepth = depthColumn?.get(selectedRow); const styles = selectedDepth !== undefined && selectedDepth > depth ? fadedFlameRectStyles : flameRectStyles; - const onMouseEnter = (): void => { - setHoveringRow(row); - window.dispatchEvent( - new CustomEvent(`flame-tooltip-update-${tooltipId}`, { - detail: {row}, - }) - ); - }; - - const onMouseLeave = (): void => { - setHoveringRow(undefined); - window.dispatchEvent( - new CustomEvent(`flame-tooltip-update-${tooltipId}`, { - detail: {row: null}, - }) - ); - }; - - const handleContextMenu = (e: React.MouseEvent): void => { - onContextMenu(e, row); - }; - - const ts = tsColumn !== null ? Number(tsColumn.get(row)) : 0; + const ts = columns.ts !== null ? Number(columns.ts.get(row)) : 0; const x = - isFlameChart && tsColumn !== null + isFlameChart && columns.ts !== null ? ((ts - Number(tsBounds[0])) / (Number(tsBounds[1]) - Number(tsBounds[0]))) * totalWidth : selectedDepth > depth ? 0 diff --git a/ui/packages/shared/profile/src/ProfileFlameGraph/FlameGraphArrow/index.tsx b/ui/packages/shared/profile/src/ProfileFlameGraph/FlameGraphArrow/index.tsx index 718bf9861ff..64e9f38df05 100644 --- a/ui/packages/shared/profile/src/ProfileFlameGraph/FlameGraphArrow/index.tsx +++ b/ui/packages/shared/profile/src/ProfileFlameGraph/FlameGraphArrow/index.tsx @@ -25,7 +25,7 @@ import {Table, tableFromIPC} from 'apache-arrow'; import {useContextMenu} from 'react-contexify'; import {FlamegraphArrow} from '@parca/client'; -import {useParcaContext} from '@parca/components'; +import {FlameGraphSkeleton, SandwichFlameGraphSkeleton, useParcaContext} from '@parca/components'; import {USER_PREFERENCES, useCurrentColorProfile, useUserPreference} from '@parca/hooks'; import {ProfileType} from '@parca/parser'; import {getColorForFeature, selectDarkMode, useAppSelector} from '@parca/store'; @@ -38,6 +38,7 @@ import ContextMenuWrapper, {ContextMenuWrapperRef} from './ContextMenuWrapper'; import {FlameNode, RowHeight, colorByColors} from './FlameGraphNodes'; import {MemoizedTooltip} from './MemoizedTooltip'; import {TooltipProvider} from './TooltipContext'; +import {useBatchedRendering} from './useBatchedRendering'; import {useScrollViewport} from './useScrollViewport'; import {useVisibleNodes} from './useVisibleNodes'; import { @@ -136,6 +137,7 @@ export const FlameGraphArrow = memo(function FlameGraphArrow({ isFlameChart = false, isRenderedAsFlamegraph = false, isInSandwichView = false, + isHalfScreen, tooltipId = 'default', maxFrameCount, isExpanded = false, @@ -163,6 +165,7 @@ export const FlameGraphArrow = memo(function FlameGraphArrow({ const svg = useRef(null); const containerRef = useRef(null); const renderStartTime = useRef(0); + const hasInitialRenderCompleted = useRef(false); const [svgElement, setSvgElement] = useState(null); @@ -291,6 +294,18 @@ export const FlameGraphArrow = memo(function FlameGraphArrow({ effectiveDepth: deferredEffectiveDepth, }); + // Add nodes in incremental batches to avoid blocking the UI + const {items: batchedNodes, isComplete: isBatchingComplete} = useBatchedRendering(visibleNodes, { + batchSize: 500, + }); + if (isBatchingComplete) { + hasInitialRenderCompleted.current = true; + } + + // Show skeleton only during initial load, not during scroll updates + const showSkeleton = + !hasInitialRenderCompleted.current && batchedNodes.length !== visibleNodes.length; + useEffect(() => { if (perf?.markInteraction != null) { renderStartTime.current = performance.now(); @@ -327,12 +342,22 @@ export const FlameGraphArrow = memo(function FlameGraphArrow({ isInSandwichView={isInSandwichView} /> + {showSkeleton && ( +
+ {isRenderedAsFlamegraph ? ( + + ) : ( + + )} +
+ )}
- {visibleNodes.map(row => ( + {batchedNodes.map(row => ( { + items: T[]; + isComplete: boolean; +} + +// useBatchedRendering - Helps in incrementally rendering items in batches to avoid UI blocking. +export const useBatchedRendering = ( + items: T[], + options: UseBatchedRenderingOptions = {} +): UseBatchedRenderingResult => { + const {batchSize = 500, batchDelay = 0} = options; + + const [renderedCount, setRenderedCount] = useState(0); + const itemsRef = useRef(items); + const rafRef = useRef(null); + const timeoutRef = useRef(null); + + useEffect(() => { + if (itemsRef.current !== items) { + itemsRef.current = items; + setRenderedCount(prev => { + if (items.length === 0) return 0; + // If new items were added (scrolling down), keep current progress + if (items.length > prev) return prev; + // If items reduced, cap to new length + return Math.min(prev, items.length); + }); + } + }, [items]); + + // Progressively render more items + useEffect(() => { + if (renderedCount === items.length) { + return; + } + + const scheduleNextBatch = (): void => { + const incrementState = (): void => { + setRenderedCount(prev => Math.min(prev + batchSize, items.length)); + }; + if (batchDelay > 0) { + timeoutRef.current = setTimeout(incrementState, batchDelay); + } else { + rafRef.current = requestAnimationFrame(incrementState); + } + }; + scheduleNextBatch(); + + return () => { + if (rafRef.current !== null) { + cancelAnimationFrame(rafRef.current); + } + if (timeoutRef.current !== null) { + clearTimeout(timeoutRef.current); + } + }; + }, [renderedCount, items.length, batchSize, batchDelay]); + + return { + items: items.slice(0, renderedCount), + isComplete: renderedCount === items.length, + }; +}; diff --git a/ui/packages/shared/profile/src/ProfileFlameGraph/FlameGraphArrow/useScrollViewport.ts b/ui/packages/shared/profile/src/ProfileFlameGraph/FlameGraphArrow/useScrollViewport.ts index 5bf1d44eb27..3a56e3adee8 100644 --- a/ui/packages/shared/profile/src/ProfileFlameGraph/FlameGraphArrow/useScrollViewport.ts +++ b/ui/packages/shared/profile/src/ProfileFlameGraph/FlameGraphArrow/useScrollViewport.ts @@ -20,6 +20,21 @@ export interface ViewportState { containerWidth: number; } +// Find the scrollable ancestor (the element with overflow: auto/scroll) +const findScrollableParent = (element: HTMLElement | null): HTMLElement | undefined => { + if (element === null) return undefined; + let current: HTMLElement | null = element.parentElement; + while (current !== null) { + const style = window.getComputedStyle(current); + const overflowY = style.overflowY; + if (overflowY === 'auto' || overflowY === 'scroll') { + return current; + } + current = current.parentElement; + } + return undefined; +}; + export const useScrollViewport = (containerRef: React.RefObject): ViewportState => { const [viewport, setViewport] = useState({ scrollTop: 0, @@ -33,11 +48,25 @@ export const useScrollViewport = (containerRef: React.RefObject) const updateViewport = useCallback(() => { if (containerRef.current !== null) { const container = containerRef.current; + const rect = container.getBoundingClientRect(); + + // Restrict container height to the visible portion on screen + // This handles cases where the container is partially off-screen + // We only want to consider the visible part for culling calculations + + const containerTop = rect.top; + const containerBottom = rect.bottom; + const viewportTop = 0; + const viewportBottom = window.innerHeight; + const visibleTop = Math.max(containerTop, viewportTop); + const visibleBottom = Math.min(containerBottom, viewportBottom); + const visibleHeight = Math.max(0, visibleBottom - visibleTop); + const scrollOffset = Math.max(0, viewportTop - containerTop); const newViewport = { - scrollTop: container.scrollTop, + scrollTop: scrollOffset, scrollLeft: container.scrollLeft, - containerHeight: container.clientHeight, + containerHeight: visibleHeight, // Only the visible portion containerWidth: container.clientWidth, }; @@ -59,6 +88,8 @@ export const useScrollViewport = (containerRef: React.RefObject) const container = containerRef.current; if (container === null) return; + const scrollableParent = findScrollableParent(container); + // ResizeObserver Strategy: // Monitor container size changes (window resize, layout shifts) // to update viewport dimensions for accurate culling calculations @@ -66,10 +97,12 @@ export const useScrollViewport = (containerRef: React.RefObject) throttledUpdateViewport(); }); - // Container Scroll Event Strategy: - // Use passive event listeners for better scroll performance - // Throttle with requestAnimationFrame to maintain 60fps target + // Listen to scroll on the actual scrollable parent + + scrollableParent?.addEventListener('scroll', throttledUpdateViewport, {passive: true}); container.addEventListener('scroll', throttledUpdateViewport, {passive: true}); + window.addEventListener('scroll', throttledUpdateViewport, {passive: true}); + resizeObserver.observe(container); // Initialize viewport state on mount @@ -77,7 +110,9 @@ export const useScrollViewport = (containerRef: React.RefObject) return () => { // Cleanup: Remove event listeners and cancel pending animations + scrollableParent?.removeEventListener('scroll', throttledUpdateViewport); container.removeEventListener('scroll', throttledUpdateViewport); + window.removeEventListener('scroll', throttledUpdateViewport); resizeObserver.disconnect(); if (throttleRef.current !== null) { cancelAnimationFrame(throttleRef.current); diff --git a/ui/packages/shared/profile/src/ProfileFlameGraph/FlameGraphArrow/useVisibleNodes.ts b/ui/packages/shared/profile/src/ProfileFlameGraph/FlameGraphArrow/useVisibleNodes.ts index d8781685879..9882061c261 100644 --- a/ui/packages/shared/profile/src/ProfileFlameGraph/FlameGraphArrow/useVisibleNodes.ts +++ b/ui/packages/shared/profile/src/ProfileFlameGraph/FlameGraphArrow/useVisibleNodes.ts @@ -85,7 +85,19 @@ export const useVisibleNodes = ({ result: number[]; }>({key: '', result: []}); + const renderedRangeRef = useRef<{minDepth: number; maxDepth: number; table: Table | null}>({ + minDepth: Infinity, + maxDepth: -Infinity, + table: null, + }); + return useMemo(() => { + // This happens when the continer is scrolled off screen, in this case we return all previously rendered nodes + // to avoid trimming the rendered nodes to zero which would cause jank when scrolling back into view + if (viewport.containerHeight === 0 && lastResultRef.current.result.length > 0) { + return lastResultRef.current.result; + } + // Create a stable key for memoization to prevent unnecessary recalculations const memoKey = `${viewport.scrollTop}-${ viewport.containerHeight @@ -96,7 +108,7 @@ export const useVisibleNodes = ({ return lastResultRef.current.result; } - if (table === null || viewport.containerHeight === 0) return []; + if (table === null) return []; const visibleRows: number[] = []; const {scrollTop, containerHeight} = viewport; @@ -104,11 +116,35 @@ export const useVisibleNodes = ({ // Viewport Culling Algorithm: // 1. Calculate visible depth range based on scroll position and container height // 2. Add 5-row buffer above/below for smooth scrolling experience - const startDepth = Math.max(0, Math.floor(scrollTop / RowHeight) - 5); - const endDepth = Math.min( - effectiveDepth, - Math.ceil((scrollTop + containerHeight) / RowHeight) + 5 + // Note: We never shrink the rendered range to avoid back and forth node removals (and in turn additions when scrolled down again) to the dom. + + const BUFFER = 15; // Buffer for smoother scrolling + + const visibleStartDepth = Math.max(0, Math.floor(scrollTop / RowHeight) - BUFFER); + const visibleDepths = Math.ceil(containerHeight / RowHeight); + const visibleEndDepth = Math.min(effectiveDepth, visibleStartDepth + visibleDepths + BUFFER); + + // Reset range if table changed (new data loaded) as this is new data + if (renderedRangeRef.current.table !== table) { + renderedRangeRef.current = { + minDepth: Infinity, + maxDepth: -Infinity, + table: table, + }; + } + + // Expand the rendered range (never shrink when scrolling up/down) + renderedRangeRef.current.minDepth = Math.min( + renderedRangeRef.current.minDepth, + visibleStartDepth ); + renderedRangeRef.current.maxDepth = Math.max( + renderedRangeRef.current.maxDepth, + visibleEndDepth + ); + + const startDepth = renderedRangeRef.current.minDepth; + const endDepth = renderedRangeRef.current.maxDepth; const cumulativeColumn = table.getChild(FIELD_CUMULATIVE); const valueOffsetColumn = table.getChild(FIELD_VALUE_OFFSET); diff --git a/ui/packages/shared/profile/src/hooks/useLabels.ts b/ui/packages/shared/profile/src/hooks/useLabels.ts index ea0f34c7744..6f1013a5f96 100644 --- a/ui/packages/shared/profile/src/hooks/useLabels.ts +++ b/ui/packages/shared/profile/src/hooks/useLabels.ts @@ -113,7 +113,9 @@ export const useLabelValues = ( }, }); - console.log('Label values query result:', {data, error, isLoading, labelName}); + useEffect(() => { + console.log('Label values query result:', {data, error, isLoading, labelName}); + }, [data, error, isLoading, labelName]); return { result: {response: data ?? [], error: error as Error},