From 8504c5337d429b46f0912d0d3ed2af8b26c9cf49 Mon Sep 17 00:00:00 2001 From: Manoj Vivek Date: Wed, 17 Dec 2025 15:15:04 +0530 Subject: [PATCH 1/3] Flamegraph rendering improvements --- .../FlameGraphArrow/FlameGraphNodes.tsx | 101 +++++++++++------- .../FlameGraphArrow/index.tsx | 26 ++++- .../FlameGraphArrow/useBatchedRendering.ts | 84 +++++++++++++++ .../FlameGraphArrow/useScrollViewport.ts | 45 +++++++- .../FlameGraphArrow/useVisibleNodes.ts | 41 ++++++- .../shared/profile/src/hooks/useLabels.ts | 4 +- 6 files changed, 249 insertions(+), 52 deletions(-) create mode 100644 ui/packages/shared/profile/src/ProfileFlameGraph/FlameGraphArrow/useBatchedRendering.ts diff --git a/ui/packages/shared/profile/src/ProfileFlameGraph/FlameGraphArrow/FlameGraphNodes.tsx b/ui/packages/shared/profile/src/ProfileFlameGraph/FlameGraphArrow/FlameGraphNodes.tsx index 57b9aa758d4..3231fa8bda2 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, {useMemo, useCallback} from 'react'; import {Table} from 'apache-arrow'; import cx from 'classnames'; @@ -101,36 +101,47 @@ 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; + // 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; - const valueOffset: bigint = - valueOffsetColumn?.get(row) !== null && valueOffsetColumn?.get(row) !== undefined - ? BigInt(valueOffsetColumn?.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; @@ -152,13 +163,22 @@ export const FlameNode = React.memo( 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; + // 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; + if ( valueOffset + cumulative <= selectionOffset || valueOffset >= selectionOffset + selectionCumulative @@ -172,9 +192,10 @@ export const FlameNode = React.memo( return <>; } + // Memoize tsBounds - only changes when profileSource changes + const tsBounds = useMemo(() => boundsFromProfileSource(profileSource), [profileSource]); + // 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 +205,35 @@ export const FlameNode = React.memo( return <>; } - const selectedDepth = depthColumn?.get(selectedRow); const styles = selectedDepth !== undefined && selectedDepth > depth ? fadedFlameRectStyles : flameRectStyles; - const onMouseEnter = (): void => { + // Memoize event handlers + const onMouseEnter = useCallback((): void => { setHoveringRow(row); window.dispatchEvent( new CustomEvent(`flame-tooltip-update-${tooltipId}`, { detail: {row}, }) ); - }; + }, [setHoveringRow, row, tooltipId]); - const onMouseLeave = (): void => { + const onMouseLeave = useCallback((): void => { setHoveringRow(undefined); window.dispatchEvent( new CustomEvent(`flame-tooltip-update-${tooltipId}`, { detail: {row: null}, }) ); - }; + }, [setHoveringRow, tooltipId]); - const handleContextMenu = (e: React.MouseEvent): void => { + const handleContextMenu = useCallback((e: React.MouseEvent): void => { onContextMenu(e, row); - }; + }, [onContextMenu, 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..e2740e27018 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,15 @@ 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 +339,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 = () => { + 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..a9fca484d5c 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,12 +116,33 @@ 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( + // 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, - Math.ceil((scrollTop + containerHeight) / RowHeight) + 5 + 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}, From 47ce66d58abda6414441fa073327788692c9478c Mon Sep 17 00:00:00 2001 From: "pre-commit-ci-lite[bot]" <117423508+pre-commit-ci-lite[bot]@users.noreply.github.com> Date: Wed, 17 Dec 2025 09:55:17 +0000 Subject: [PATCH 2/3] [pre-commit.ci lite] apply automatic fixes --- .../FlameGraphArrow/FlameGraphNodes.tsx | 52 +++++++++++-------- .../FlameGraphArrow/index.tsx | 13 +++-- .../FlameGraphArrow/useVisibleNodes.ts | 15 +++--- 3 files changed, 48 insertions(+), 32 deletions(-) diff --git a/ui/packages/shared/profile/src/ProfileFlameGraph/FlameGraphArrow/FlameGraphNodes.tsx b/ui/packages/shared/profile/src/ProfileFlameGraph/FlameGraphArrow/FlameGraphNodes.tsx index 3231fa8bda2..f38ca978d86 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, useCallback} from 'react'; +import React, {useCallback, useMemo} from 'react'; import {Table} from 'apache-arrow'; import cx from 'classnames'; @@ -102,16 +102,19 @@ export const FlameNode = React.memo( tooltipId = 'default', }: FlameNodeProps): React.JSX.Element { // 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]); + 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); @@ -120,8 +123,10 @@ export const FlameNode = React.memo( 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 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 = @@ -129,10 +134,10 @@ export const FlameNode = React.memo( ? BigInt(columns.valueOffset?.get(row)) : 0n; - return { mappingFile, functionName, cumulative, diff, filename, depth, valueOffset }; + return {mappingFile, functionName, cumulative, diff, filename, depth, valueOffset}; }, [columns, row]); - const { mappingFile, functionName, cumulative, diff, filename, depth, valueOffset } = rowData; + const {mappingFile, functionName, cumulative, diff, filename, depth, valueOffset} = rowData; const colorAttribute = colorBy === 'filename' ? filename : colorBy === 'binary' ? mappingFile : null; @@ -171,13 +176,15 @@ export const FlameNode = React.memo( ? BigInt(columns.valueOffset?.get(selectedRow)) : 0n; const selectionCumulative = - columns.cumulative?.get(selectedRow) !== null ? BigInt(columns.cumulative?.get(selectedRow)) : 0n; + 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 }; + return {selectionOffset, selectionCumulative, selectedDepth, total}; }, [columns, selectedRow]); - const { selectionOffset, selectionCumulative, selectedDepth, total } = selectionData; + const {selectionOffset, selectionCumulative, selectedDepth, total} = selectionData; if ( valueOffset + cumulative <= selectionOffset || @@ -227,9 +234,12 @@ export const FlameNode = React.memo( ); }, [setHoveringRow, tooltipId]); - const handleContextMenu = useCallback((e: React.MouseEvent): void => { - onContextMenu(e, row); - }, [onContextMenu, row]); + const handleContextMenu = useCallback( + (e: React.MouseEvent): void => { + onContextMenu(e, row); + }, + [onContextMenu, row] + ); const ts = columns.ts !== null ? Number(columns.ts.get(row)) : 0; const x = diff --git a/ui/packages/shared/profile/src/ProfileFlameGraph/FlameGraphArrow/index.tsx b/ui/packages/shared/profile/src/ProfileFlameGraph/FlameGraphArrow/index.tsx index e2740e27018..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 { FlameGraphSkeleton, SandwichFlameGraphSkeleton, 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,7 +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 {useBatchedRendering} from './useBatchedRendering'; import {useScrollViewport} from './useScrollViewport'; import {useVisibleNodes} from './useVisibleNodes'; import { @@ -295,13 +295,16 @@ export const FlameGraphArrow = memo(function FlameGraphArrow({ }); // Add nodes in incremental batches to avoid blocking the UI - const { items: batchedNodes, isComplete: isBatchingComplete } = useBatchedRendering(visibleNodes, { batchSize: 500 }); + 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; + const showSkeleton = + !hasInitialRenderCompleted.current && batchedNodes.length !== visibleNodes.length; useEffect(() => { if (perf?.markInteraction != null) { @@ -364,7 +367,7 @@ export const FlameGraphArrow = memo(function FlameGraphArrow({ preserveAspectRatio="xMinYMid" ref={svg} > - {(batchedNodes).map(row => ( + {batchedNodes.map(row => ( Date: Wed, 17 Dec 2025 15:57:07 +0530 Subject: [PATCH 3/3] Linter fixes --- .../FlameGraphArrow/FlameGraphNodes.tsx | 63 ++++++++++--------- .../FlameGraphArrow/useBatchedRendering.ts | 4 +- 2 files changed, 34 insertions(+), 33 deletions(-) diff --git a/ui/packages/shared/profile/src/ProfileFlameGraph/FlameGraphArrow/FlameGraphNodes.tsx b/ui/packages/shared/profile/src/ProfileFlameGraph/FlameGraphArrow/FlameGraphNodes.tsx index 3231fa8bda2..0a572fa0d5c 100644 --- a/ui/packages/shared/profile/src/ProfileFlameGraph/FlameGraphArrow/FlameGraphNodes.tsx +++ b/ui/packages/shared/profile/src/ProfileFlameGraph/FlameGraphArrow/FlameGraphNodes.tsx @@ -158,11 +158,6 @@ export const FlameNode = React.memo( return row === 0 ? 'root' : nodeLabel(table, row, binaries.length > 1); }, [table, row, binaries]); - // Hide frames beyond effective depth limit - if (effectiveDepth !== undefined && depth > effectiveDepth) { - return <>; - } - // Memoize selection data - only changes when selectedRow changes const selectionData = useMemo(() => { const selectionOffset = @@ -179,35 +174,9 @@ export const FlameNode = React.memo( const { selectionOffset, selectionCumulative, selectedDepth, total } = selectionData; - if ( - valueOffset + cumulative <= selectionOffset || - valueOffset >= selectionOffset + selectionCumulative - ) { - // If the end of the node is before the selection offset or the start of the node is after the selection offset + totalWidth, we don't render it. - return <>; - } - - if (row === 0 && (isFlameChart || isInSandwichView)) { - // The root node is not rendered in the flame chart or sandwich view, so we return null. - return <>; - } - // Memoize tsBounds - only changes when profileSource changes const tsBounds = useMemo(() => boundsFromProfileSource(profileSource), [profileSource]); - // 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 totalRatio = cumulative > total ? 1 : Number(cumulative) / Number(total); - const width: number = isFlameChart - ? (Number(cumulative) / (Number(tsBounds[1]) - Number(tsBounds[0]))) * totalWidth - : totalRatio * totalWidth; - - if (width <= 1) { - return <>; - } - - const styles = - selectedDepth !== undefined && selectedDepth > depth ? fadedFlameRectStyles : flameRectStyles; - // Memoize event handlers const onMouseEnter = useCallback((): void => { setHoveringRow(row); @@ -231,6 +200,38 @@ export const FlameNode = React.memo( 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 <>; + } + + if ( + valueOffset + cumulative <= selectionOffset || + valueOffset >= selectionOffset + selectionCumulative + ) { + // If the end of the node is before the selection offset or the start of the node is after the selection offset + totalWidth, we don't render it. + return <>; + } + + if (row === 0 && (isFlameChart || isInSandwichView)) { + // The root node is not rendered in the flame chart or sandwich view, so we return null. + return <>; + } + + // 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 totalRatio = cumulative > total ? 1 : Number(cumulative) / Number(total); + const width: number = isFlameChart + ? (Number(cumulative) / (Number(tsBounds[1]) - Number(tsBounds[0]))) * totalWidth + : totalRatio * totalWidth; + + if (width <= 1) { + return <>; + } + + const styles = + selectedDepth !== undefined && selectedDepth > depth ? fadedFlameRectStyles : flameRectStyles; + const ts = columns.ts !== null ? Number(columns.ts.get(row)) : 0; const x = isFlameChart && columns.ts !== null diff --git a/ui/packages/shared/profile/src/ProfileFlameGraph/FlameGraphArrow/useBatchedRendering.ts b/ui/packages/shared/profile/src/ProfileFlameGraph/FlameGraphArrow/useBatchedRendering.ts index a509e2bf632..f5a612bae96 100644 --- a/ui/packages/shared/profile/src/ProfileFlameGraph/FlameGraphArrow/useBatchedRendering.ts +++ b/ui/packages/shared/profile/src/ProfileFlameGraph/FlameGraphArrow/useBatchedRendering.ts @@ -24,7 +24,7 @@ interface UseBatchedRenderingResult { isComplete: boolean; } -//useBatchedRendering - Helps in incrementally rendering items in batches to avoid UI blocking. +// useBatchedRendering - Helps in incrementally rendering items in batches to avoid UI blocking. export const useBatchedRendering = ( items: T[], options: UseBatchedRenderingOptions = {} @@ -56,7 +56,7 @@ export const useBatchedRendering = ( } const scheduleNextBatch = (): void => { - const incrementState = () => { + const incrementState = (): void => { setRenderedCount(prev => Math.min(prev + batchSize, items.length)); }; if (batchDelay > 0) {