@parca/profile 0.19.95 → 0.19.103

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (44) hide show
  1. package/CHANGELOG.md +32 -0
  2. package/dist/ProfileFlameGraph/FlameGraphArrow/FlameGraphNodes.d.ts.map +1 -1
  3. package/dist/ProfileFlameGraph/FlameGraphArrow/FlameGraphNodes.js +65 -45
  4. package/dist/ProfileFlameGraph/FlameGraphArrow/index.d.ts.map +1 -1
  5. package/dist/ProfileFlameGraph/FlameGraphArrow/index.js +16 -4
  6. package/dist/ProfileFlameGraph/FlameGraphArrow/useBatchedRendering.d.ts +11 -0
  7. package/dist/ProfileFlameGraph/FlameGraphArrow/useBatchedRendering.d.ts.map +1 -0
  8. package/dist/ProfileFlameGraph/FlameGraphArrow/useBatchedRendering.js +65 -0
  9. package/dist/ProfileFlameGraph/FlameGraphArrow/useScrollViewport.d.ts.map +1 -1
  10. package/dist/ProfileFlameGraph/FlameGraphArrow/useScrollViewport.js +35 -5
  11. package/dist/ProfileFlameGraph/FlameGraphArrow/useVisibleNodes.d.ts.map +1 -1
  12. package/dist/ProfileFlameGraph/FlameGraphArrow/useVisibleNodes.js +29 -3
  13. package/dist/ProfileSelector/MetricsGraphSection.d.ts +1 -1
  14. package/dist/ProfileSelector/MetricsGraphSection.d.ts.map +1 -1
  15. package/dist/ProfileSelector/MetricsGraphSection.js +1 -1
  16. package/dist/ProfileSelector/index.d.ts.map +1 -1
  17. package/dist/ProfileSelector/index.js +1 -2
  18. package/dist/ProfileView/hooks/useResetStateOnProfileTypeChange.d.ts.map +1 -1
  19. package/dist/ProfileView/hooks/useResetStateOnProfileTypeChange.js +8 -0
  20. package/dist/SimpleMatchers/Select.d.ts.map +1 -1
  21. package/dist/SimpleMatchers/Select.js +3 -3
  22. package/dist/hooks/useLabels.d.ts.map +1 -1
  23. package/dist/hooks/useLabels.js +7 -2
  24. package/dist/hooks/useQueryState.d.ts.map +1 -1
  25. package/dist/hooks/useQueryState.js +53 -23
  26. package/dist/hooks/useQueryState.test.js +32 -22
  27. package/dist/styles.css +1 -1
  28. package/dist/useSumBy.d.ts +10 -2
  29. package/dist/useSumBy.d.ts.map +1 -1
  30. package/dist/useSumBy.js +30 -7
  31. package/package.json +15 -10
  32. package/src/ProfileFlameGraph/FlameGraphArrow/FlameGraphNodes.tsx +89 -57
  33. package/src/ProfileFlameGraph/FlameGraphArrow/index.tsx +27 -2
  34. package/src/ProfileFlameGraph/FlameGraphArrow/useBatchedRendering.ts +84 -0
  35. package/src/ProfileFlameGraph/FlameGraphArrow/useScrollViewport.ts +40 -5
  36. package/src/ProfileFlameGraph/FlameGraphArrow/useVisibleNodes.ts +41 -5
  37. package/src/ProfileSelector/MetricsGraphSection.tsx +2 -2
  38. package/src/ProfileSelector/index.tsx +1 -5
  39. package/src/ProfileView/hooks/useResetStateOnProfileTypeChange.ts +8 -0
  40. package/src/SimpleMatchers/Select.tsx +3 -3
  41. package/src/hooks/useLabels.ts +8 -2
  42. package/src/hooks/useQueryState.test.tsx +41 -22
  43. package/src/hooks/useQueryState.ts +72 -31
  44. package/src/useSumBy.ts +58 -4
package/package.json CHANGED
@@ -1,20 +1,20 @@
1
1
  {
2
2
  "name": "@parca/profile",
3
- "version": "0.19.95",
3
+ "version": "0.19.103",
4
4
  "description": "Profile viewing libraries",
5
5
  "dependencies": {
6
6
  "@floating-ui/react": "^0.27.12",
7
7
  "@headlessui/react": "^1.7.19",
8
8
  "@iconify/react": "^4.0.0",
9
- "@parca/client": "0.17.11",
10
- "@parca/components": "0.16.386",
11
- "@parca/dynamicsize": "0.16.67",
12
- "@parca/hooks": "0.0.111",
13
- "@parca/icons": "0.16.74",
14
- "@parca/parser": "0.16.81",
15
- "@parca/store": "0.16.194",
9
+ "@parca/client": "0.17.16",
10
+ "@parca/components": "0.16.392",
11
+ "@parca/dynamicsize": "0.16.72",
12
+ "@parca/hooks": "0.0.116",
13
+ "@parca/icons": "0.16.79",
14
+ "@parca/parser": "0.16.86",
15
+ "@parca/store": "0.16.199",
16
16
  "@parca/test-utils": "0.0.17",
17
- "@parca/utilities": "0.0.117",
17
+ "@parca/utilities": "0.0.122",
18
18
  "@popperjs/core": "^2.11.8",
19
19
  "@protobuf-ts/runtime-rpc": "^2.5.0",
20
20
  "@storybook/preview-api": "^8.4.3",
@@ -75,9 +75,14 @@
75
75
  "keywords": [],
76
76
  "author": "",
77
77
  "license": "ISC",
78
+ "repository": {
79
+ "type": "git",
80
+ "url": "https://github.com/parca-dev/parca",
81
+ "directory": "ui/packages/shared/profile"
82
+ },
78
83
  "publishConfig": {
79
84
  "access": "public",
80
85
  "registry": "https://registry.npmjs.org/"
81
86
  },
82
- "gitHead": "3a8c3331a1a9c7f354f0c2915d822b9b0c1c5014"
87
+ "gitHead": "14996d0bb0fdf1193c1555e4d171aefd5138303b"
83
88
  }
@@ -11,7 +11,7 @@
11
11
  // See the License for the specific language governing permissions and
12
12
  // limitations under the License.
13
13
 
14
- import React, {useMemo} from 'react';
14
+ import React, {useCallback, useMemo} from 'react';
15
15
 
16
16
  import {Table} from 'apache-arrow';
17
17
  import cx from 'classnames';
@@ -101,36 +101,52 @@ export const FlameNode = React.memo(
101
101
  effectiveDepth,
102
102
  tooltipId = 'default',
103
103
  }: FlameNodeProps): React.JSX.Element {
104
- // get the columns to read from
105
- const mappingColumn = table.getChild(FIELD_MAPPING_FILE);
106
- const functionNameColumn = table.getChild(FIELD_FUNCTION_NAME);
107
- const cumulativeColumn = table.getChild(FIELD_CUMULATIVE);
108
- const depthColumn = table.getChild(FIELD_DEPTH);
109
- const diffColumn = table.getChild(FIELD_DIFF);
110
- const filenameColumn = table.getChild(FIELD_FUNCTION_FILE_NAME);
111
- const valueOffsetColumn = table.getChild(FIELD_VALUE_OFFSET);
112
- const tsColumn = table.getChild(FIELD_TIMESTAMP);
104
+ // Memoize column references - only changes when table changes
105
+ const columns = useMemo(
106
+ () => ({
107
+ mapping: table.getChild(FIELD_MAPPING_FILE),
108
+ functionName: table.getChild(FIELD_FUNCTION_NAME),
109
+ cumulative: table.getChild(FIELD_CUMULATIVE),
110
+ depth: table.getChild(FIELD_DEPTH),
111
+ diff: table.getChild(FIELD_DIFF),
112
+ filename: table.getChild(FIELD_FUNCTION_FILE_NAME),
113
+ valueOffset: table.getChild(FIELD_VALUE_OFFSET),
114
+ ts: table.getChild(FIELD_TIMESTAMP),
115
+ }),
116
+ [table]
117
+ );
113
118
 
114
119
  // get the actual values from the columns
115
120
  const binaries = useAppSelector(selectBinaries);
116
121
 
117
- const mappingFile: string | null = arrowToString(mappingColumn?.get(row));
118
- const functionName: string | null = arrowToString(functionNameColumn?.get(row));
119
- const cumulative = cumulativeColumn?.get(row) != null ? BigInt(cumulativeColumn?.get(row)) : 0n;
120
- const diff: bigint | null = diffColumn?.get(row) != null ? BigInt(diffColumn?.get(row)) : null;
121
- const filename: string | null = arrowToString(filenameColumn?.get(row));
122
- const depth: number = depthColumn?.get(row) ?? 0;
123
-
124
- const valueOffset: bigint =
125
- valueOffsetColumn?.get(row) !== null && valueOffsetColumn?.get(row) !== undefined
126
- ? BigInt(valueOffsetColumn?.get(row))
127
- : 0n;
122
+ // Memoize row data extraction - only changes when table or row changes
123
+ const rowData = useMemo(() => {
124
+ const mappingFile: string | null = arrowToString(columns.mapping?.get(row));
125
+ const functionName: string | null = arrowToString(columns.functionName?.get(row));
126
+ const cumulative =
127
+ columns.cumulative?.get(row) != null ? BigInt(columns.cumulative?.get(row)) : 0n;
128
+ const diff: bigint | null =
129
+ columns.diff?.get(row) != null ? BigInt(columns.diff?.get(row)) : null;
130
+ const filename: string | null = arrowToString(columns.filename?.get(row));
131
+ const depth: number = columns.depth?.get(row) ?? 0;
132
+ const valueOffset: bigint =
133
+ columns.valueOffset?.get(row) !== null && columns.valueOffset?.get(row) !== undefined
134
+ ? BigInt(columns.valueOffset?.get(row))
135
+ : 0n;
136
+
137
+ return {mappingFile, functionName, cumulative, diff, filename, depth, valueOffset};
138
+ }, [columns, row]);
139
+
140
+ const {mappingFile, functionName, cumulative, diff, filename, depth, valueOffset} = rowData;
128
141
 
129
142
  const colorAttribute =
130
143
  colorBy === 'filename' ? filename : colorBy === 'binary' ? mappingFile : null;
131
144
 
132
- const hoveringName =
133
- hoveringRow !== undefined ? arrowToString(functionNameColumn?.get(hoveringRow)) : '';
145
+ // Memoize hovering name lookup
146
+ const hoveringName = useMemo(() => {
147
+ return hoveringRow !== undefined ? arrowToString(columns.functionName?.get(hoveringRow)) : '';
148
+ }, [columns.functionName, hoveringRow]);
149
+
134
150
  const shouldBeHighlighted =
135
151
  functionName != null && hoveringName != null && functionName === hoveringName;
136
152
 
@@ -147,18 +163,59 @@ export const FlameNode = React.memo(
147
163
  return row === 0 ? 'root' : nodeLabel(table, row, binaries.length > 1);
148
164
  }, [table, row, binaries]);
149
165
 
166
+ // Memoize selection data - only changes when selectedRow changes
167
+ const selectionData = useMemo(() => {
168
+ const selectionOffset =
169
+ columns.valueOffset?.get(selectedRow) !== null &&
170
+ columns.valueOffset?.get(selectedRow) !== undefined
171
+ ? BigInt(columns.valueOffset?.get(selectedRow))
172
+ : 0n;
173
+ const selectionCumulative =
174
+ columns.cumulative?.get(selectedRow) !== null
175
+ ? BigInt(columns.cumulative?.get(selectedRow))
176
+ : 0n;
177
+ const selectedDepth = columns.depth?.get(selectedRow);
178
+ const total = columns.cumulative?.get(selectedRow);
179
+ return {selectionOffset, selectionCumulative, selectedDepth, total};
180
+ }, [columns, selectedRow]);
181
+
182
+ const {selectionOffset, selectionCumulative, selectedDepth, total} = selectionData;
183
+
184
+ // Memoize tsBounds - only changes when profileSource changes
185
+ const tsBounds = useMemo(() => boundsFromProfileSource(profileSource), [profileSource]);
186
+
187
+ // Memoize event handlers
188
+ const onMouseEnter = useCallback((): void => {
189
+ setHoveringRow(row);
190
+ window.dispatchEvent(
191
+ new CustomEvent(`flame-tooltip-update-${tooltipId}`, {
192
+ detail: {row},
193
+ })
194
+ );
195
+ }, [setHoveringRow, row, tooltipId]);
196
+
197
+ const onMouseLeave = useCallback((): void => {
198
+ setHoveringRow(undefined);
199
+ window.dispatchEvent(
200
+ new CustomEvent(`flame-tooltip-update-${tooltipId}`, {
201
+ detail: {row: null},
202
+ })
203
+ );
204
+ }, [setHoveringRow, tooltipId]);
205
+
206
+ const handleContextMenu = useCallback(
207
+ (e: React.MouseEvent): void => {
208
+ onContextMenu(e, row);
209
+ },
210
+ [onContextMenu, row]
211
+ );
212
+
213
+ // Early returns - all hooks must be called before this point
150
214
  // Hide frames beyond effective depth limit
151
215
  if (effectiveDepth !== undefined && depth > effectiveDepth) {
152
216
  return <></>;
153
217
  }
154
218
 
155
- const selectionOffset =
156
- valueOffsetColumn?.get(selectedRow) !== null &&
157
- valueOffsetColumn?.get(selectedRow) !== undefined
158
- ? BigInt(valueOffsetColumn?.get(selectedRow))
159
- : 0n;
160
- const selectionCumulative =
161
- cumulativeColumn?.get(selectedRow) !== null ? BigInt(cumulativeColumn?.get(selectedRow)) : 0n;
162
219
  if (
163
220
  valueOffset + cumulative <= selectionOffset ||
164
221
  valueOffset >= selectionOffset + selectionCumulative
@@ -173,8 +230,6 @@ export const FlameNode = React.memo(
173
230
  }
174
231
 
175
232
  // 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.
176
- const tsBounds = boundsFromProfileSource(profileSource);
177
- const total = cumulativeColumn?.get(selectedRow);
178
233
  const totalRatio = cumulative > total ? 1 : Number(cumulative) / Number(total);
179
234
  const width: number = isFlameChart
180
235
  ? (Number(cumulative) / (Number(tsBounds[1]) - Number(tsBounds[0]))) * totalWidth
@@ -184,35 +239,12 @@ export const FlameNode = React.memo(
184
239
  return <></>;
185
240
  }
186
241
 
187
- const selectedDepth = depthColumn?.get(selectedRow);
188
242
  const styles =
189
243
  selectedDepth !== undefined && selectedDepth > depth ? fadedFlameRectStyles : flameRectStyles;
190
244
 
191
- const onMouseEnter = (): void => {
192
- setHoveringRow(row);
193
- window.dispatchEvent(
194
- new CustomEvent(`flame-tooltip-update-${tooltipId}`, {
195
- detail: {row},
196
- })
197
- );
198
- };
199
-
200
- const onMouseLeave = (): void => {
201
- setHoveringRow(undefined);
202
- window.dispatchEvent(
203
- new CustomEvent(`flame-tooltip-update-${tooltipId}`, {
204
- detail: {row: null},
205
- })
206
- );
207
- };
208
-
209
- const handleContextMenu = (e: React.MouseEvent): void => {
210
- onContextMenu(e, row);
211
- };
212
-
213
- const ts = tsColumn !== null ? Number(tsColumn.get(row)) : 0;
245
+ const ts = columns.ts !== null ? Number(columns.ts.get(row)) : 0;
214
246
  const x =
215
- isFlameChart && tsColumn !== null
247
+ isFlameChart && columns.ts !== null
216
248
  ? ((ts - Number(tsBounds[0])) / (Number(tsBounds[1]) - Number(tsBounds[0]))) * totalWidth
217
249
  : selectedDepth > depth
218
250
  ? 0
@@ -25,7 +25,7 @@ import {Table, tableFromIPC} from 'apache-arrow';
25
25
  import {useContextMenu} from 'react-contexify';
26
26
 
27
27
  import {FlamegraphArrow} from '@parca/client';
28
- import {useParcaContext} from '@parca/components';
28
+ import {FlameGraphSkeleton, SandwichFlameGraphSkeleton, useParcaContext} from '@parca/components';
29
29
  import {USER_PREFERENCES, useCurrentColorProfile, useUserPreference} from '@parca/hooks';
30
30
  import {ProfileType} from '@parca/parser';
31
31
  import {getColorForFeature, selectDarkMode, useAppSelector} from '@parca/store';
@@ -38,6 +38,7 @@ import ContextMenuWrapper, {ContextMenuWrapperRef} from './ContextMenuWrapper';
38
38
  import {FlameNode, RowHeight, colorByColors} from './FlameGraphNodes';
39
39
  import {MemoizedTooltip} from './MemoizedTooltip';
40
40
  import {TooltipProvider} from './TooltipContext';
41
+ import {useBatchedRendering} from './useBatchedRendering';
41
42
  import {useScrollViewport} from './useScrollViewport';
42
43
  import {useVisibleNodes} from './useVisibleNodes';
43
44
  import {
@@ -136,6 +137,7 @@ export const FlameGraphArrow = memo(function FlameGraphArrow({
136
137
  isFlameChart = false,
137
138
  isRenderedAsFlamegraph = false,
138
139
  isInSandwichView = false,
140
+ isHalfScreen,
139
141
  tooltipId = 'default',
140
142
  maxFrameCount,
141
143
  isExpanded = false,
@@ -163,6 +165,7 @@ export const FlameGraphArrow = memo(function FlameGraphArrow({
163
165
  const svg = useRef(null);
164
166
  const containerRef = useRef<HTMLDivElement>(null);
165
167
  const renderStartTime = useRef<number>(0);
168
+ const hasInitialRenderCompleted = useRef(false);
166
169
 
167
170
  const [svgElement, setSvgElement] = useState<SVGSVGElement | null>(null);
168
171
 
@@ -291,6 +294,18 @@ export const FlameGraphArrow = memo(function FlameGraphArrow({
291
294
  effectiveDepth: deferredEffectiveDepth,
292
295
  });
293
296
 
297
+ // Add nodes in incremental batches to avoid blocking the UI
298
+ const {items: batchedNodes, isComplete: isBatchingComplete} = useBatchedRendering(visibleNodes, {
299
+ batchSize: 500,
300
+ });
301
+ if (isBatchingComplete) {
302
+ hasInitialRenderCompleted.current = true;
303
+ }
304
+
305
+ // Show skeleton only during initial load, not during scroll updates
306
+ const showSkeleton =
307
+ !hasInitialRenderCompleted.current && batchedNodes.length !== visibleNodes.length;
308
+
294
309
  useEffect(() => {
295
310
  if (perf?.markInteraction != null) {
296
311
  renderStartTime.current = performance.now();
@@ -327,12 +342,22 @@ export const FlameGraphArrow = memo(function FlameGraphArrow({
327
342
  isInSandwichView={isInSandwichView}
328
343
  />
329
344
  <MemoizedTooltip contextElement={svgElement} dockedMetainfo={dockedMetainfo} />
345
+ {showSkeleton && (
346
+ <div className="absolute inset-0 z-10">
347
+ {isRenderedAsFlamegraph ? (
348
+ <SandwichFlameGraphSkeleton isHalfScreen={isHalfScreen} isDarkMode={isDarkMode} />
349
+ ) : (
350
+ <FlameGraphSkeleton isHalfScreen={isHalfScreen} isDarkMode={isDarkMode} />
351
+ )}
352
+ </div>
353
+ )}
330
354
  <div
331
355
  ref={containerRef}
332
356
  className="overflow-auto scrollbar-thin scrollbar-thumb-gray-400 scrollbar-track-gray-100 dark:scrollbar-thumb-gray-600 dark:scrollbar-track-gray-800 will-change-transform scroll-smooth webkit-overflow-scrolling-touch contain"
333
357
  style={{
334
358
  width: width ?? '100%',
335
359
  contain: 'layout style paint',
360
+ visibility: !showSkeleton ? 'visible' : 'hidden',
336
361
  }}
337
362
  >
338
363
  <svg
@@ -342,7 +367,7 @@ export const FlameGraphArrow = memo(function FlameGraphArrow({
342
367
  preserveAspectRatio="xMinYMid"
343
368
  ref={svg}
344
369
  >
345
- {visibleNodes.map(row => (
370
+ {batchedNodes.map(row => (
346
371
  <FlameNode
347
372
  key={row}
348
373
  table={table}
@@ -0,0 +1,84 @@
1
+ // Copyright 2022 The Parca Authors
2
+ // Licensed under the Apache License, Version 2.0 (the "License");
3
+ // you may not use this file except in compliance with the License.
4
+ // You may obtain a copy of the License at
5
+ //
6
+ // http://www.apache.org/licenses/LICENSE-2.0
7
+ //
8
+ // Unless required by applicable law or agreed to in writing, software
9
+ // distributed under the License is distributed on an "AS IS" BASIS,
10
+ // WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
11
+ // See the License for the specific language governing permissions and
12
+ // limitations under the License.
13
+
14
+ import {useEffect, useRef, useState} from 'react';
15
+
16
+ interface UseBatchedRenderingOptions {
17
+ batchSize?: number;
18
+ // Delay between batches in ms (0 = next animation frame)
19
+ batchDelay?: number;
20
+ }
21
+
22
+ interface UseBatchedRenderingResult<T> {
23
+ items: T[];
24
+ isComplete: boolean;
25
+ }
26
+
27
+ // useBatchedRendering - Helps in incrementally rendering items in batches to avoid UI blocking.
28
+ export const useBatchedRendering = <T>(
29
+ items: T[],
30
+ options: UseBatchedRenderingOptions = {}
31
+ ): UseBatchedRenderingResult<T> => {
32
+ const {batchSize = 500, batchDelay = 0} = options;
33
+
34
+ const [renderedCount, setRenderedCount] = useState(0);
35
+ const itemsRef = useRef(items);
36
+ const rafRef = useRef<number | null>(null);
37
+ const timeoutRef = useRef<NodeJS.Timeout | null>(null);
38
+
39
+ useEffect(() => {
40
+ if (itemsRef.current !== items) {
41
+ itemsRef.current = items;
42
+ setRenderedCount(prev => {
43
+ if (items.length === 0) return 0;
44
+ // If new items were added (scrolling down), keep current progress
45
+ if (items.length > prev) return prev;
46
+ // If items reduced, cap to new length
47
+ return Math.min(prev, items.length);
48
+ });
49
+ }
50
+ }, [items]);
51
+
52
+ // Progressively render more items
53
+ useEffect(() => {
54
+ if (renderedCount === items.length) {
55
+ return;
56
+ }
57
+
58
+ const scheduleNextBatch = (): void => {
59
+ const incrementState = (): void => {
60
+ setRenderedCount(prev => Math.min(prev + batchSize, items.length));
61
+ };
62
+ if (batchDelay > 0) {
63
+ timeoutRef.current = setTimeout(incrementState, batchDelay);
64
+ } else {
65
+ rafRef.current = requestAnimationFrame(incrementState);
66
+ }
67
+ };
68
+ scheduleNextBatch();
69
+
70
+ return () => {
71
+ if (rafRef.current !== null) {
72
+ cancelAnimationFrame(rafRef.current);
73
+ }
74
+ if (timeoutRef.current !== null) {
75
+ clearTimeout(timeoutRef.current);
76
+ }
77
+ };
78
+ }, [renderedCount, items.length, batchSize, batchDelay]);
79
+
80
+ return {
81
+ items: items.slice(0, renderedCount),
82
+ isComplete: renderedCount === items.length,
83
+ };
84
+ };
@@ -20,6 +20,21 @@ export interface ViewportState {
20
20
  containerWidth: number;
21
21
  }
22
22
 
23
+ // Find the scrollable ancestor (the element with overflow: auto/scroll)
24
+ const findScrollableParent = (element: HTMLElement | null): HTMLElement | undefined => {
25
+ if (element === null) return undefined;
26
+ let current: HTMLElement | null = element.parentElement;
27
+ while (current !== null) {
28
+ const style = window.getComputedStyle(current);
29
+ const overflowY = style.overflowY;
30
+ if (overflowY === 'auto' || overflowY === 'scroll') {
31
+ return current;
32
+ }
33
+ current = current.parentElement;
34
+ }
35
+ return undefined;
36
+ };
37
+
23
38
  export const useScrollViewport = (containerRef: React.RefObject<HTMLDivElement>): ViewportState => {
24
39
  const [viewport, setViewport] = useState<ViewportState>({
25
40
  scrollTop: 0,
@@ -33,11 +48,25 @@ export const useScrollViewport = (containerRef: React.RefObject<HTMLDivElement>)
33
48
  const updateViewport = useCallback(() => {
34
49
  if (containerRef.current !== null) {
35
50
  const container = containerRef.current;
51
+ const rect = container.getBoundingClientRect();
52
+
53
+ // Restrict container height to the visible portion on screen
54
+ // This handles cases where the container is partially off-screen
55
+ // We only want to consider the visible part for culling calculations
56
+
57
+ const containerTop = rect.top;
58
+ const containerBottom = rect.bottom;
59
+ const viewportTop = 0;
60
+ const viewportBottom = window.innerHeight;
61
+ const visibleTop = Math.max(containerTop, viewportTop);
62
+ const visibleBottom = Math.min(containerBottom, viewportBottom);
63
+ const visibleHeight = Math.max(0, visibleBottom - visibleTop);
64
+ const scrollOffset = Math.max(0, viewportTop - containerTop);
36
65
 
37
66
  const newViewport = {
38
- scrollTop: container.scrollTop,
67
+ scrollTop: scrollOffset,
39
68
  scrollLeft: container.scrollLeft,
40
- containerHeight: container.clientHeight,
69
+ containerHeight: visibleHeight, // Only the visible portion
41
70
  containerWidth: container.clientWidth,
42
71
  };
43
72
 
@@ -59,6 +88,8 @@ export const useScrollViewport = (containerRef: React.RefObject<HTMLDivElement>)
59
88
  const container = containerRef.current;
60
89
  if (container === null) return;
61
90
 
91
+ const scrollableParent = findScrollableParent(container);
92
+
62
93
  // ResizeObserver Strategy:
63
94
  // Monitor container size changes (window resize, layout shifts)
64
95
  // to update viewport dimensions for accurate culling calculations
@@ -66,10 +97,12 @@ export const useScrollViewport = (containerRef: React.RefObject<HTMLDivElement>)
66
97
  throttledUpdateViewport();
67
98
  });
68
99
 
69
- // Container Scroll Event Strategy:
70
- // Use passive event listeners for better scroll performance
71
- // Throttle with requestAnimationFrame to maintain 60fps target
100
+ // Listen to scroll on the actual scrollable parent
101
+
102
+ scrollableParent?.addEventListener('scroll', throttledUpdateViewport, {passive: true});
72
103
  container.addEventListener('scroll', throttledUpdateViewport, {passive: true});
104
+ window.addEventListener('scroll', throttledUpdateViewport, {passive: true});
105
+
73
106
  resizeObserver.observe(container);
74
107
 
75
108
  // Initialize viewport state on mount
@@ -77,7 +110,9 @@ export const useScrollViewport = (containerRef: React.RefObject<HTMLDivElement>)
77
110
 
78
111
  return () => {
79
112
  // Cleanup: Remove event listeners and cancel pending animations
113
+ scrollableParent?.removeEventListener('scroll', throttledUpdateViewport);
80
114
  container.removeEventListener('scroll', throttledUpdateViewport);
115
+ window.removeEventListener('scroll', throttledUpdateViewport);
81
116
  resizeObserver.disconnect();
82
117
  if (throttleRef.current !== null) {
83
118
  cancelAnimationFrame(throttleRef.current);
@@ -85,7 +85,19 @@ export const useVisibleNodes = ({
85
85
  result: number[];
86
86
  }>({key: '', result: []});
87
87
 
88
+ const renderedRangeRef = useRef<{minDepth: number; maxDepth: number; table: Table<any> | null}>({
89
+ minDepth: Infinity,
90
+ maxDepth: -Infinity,
91
+ table: null,
92
+ });
93
+
88
94
  return useMemo(() => {
95
+ // This happens when the continer is scrolled off screen, in this case we return all previously rendered nodes
96
+ // to avoid trimming the rendered nodes to zero which would cause jank when scrolling back into view
97
+ if (viewport.containerHeight === 0 && lastResultRef.current.result.length > 0) {
98
+ return lastResultRef.current.result;
99
+ }
100
+
89
101
  // Create a stable key for memoization to prevent unnecessary recalculations
90
102
  const memoKey = `${viewport.scrollTop}-${
91
103
  viewport.containerHeight
@@ -96,7 +108,7 @@ export const useVisibleNodes = ({
96
108
  return lastResultRef.current.result;
97
109
  }
98
110
 
99
- if (table === null || viewport.containerHeight === 0) return [];
111
+ if (table === null) return [];
100
112
 
101
113
  const visibleRows: number[] = [];
102
114
  const {scrollTop, containerHeight} = viewport;
@@ -104,11 +116,35 @@ export const useVisibleNodes = ({
104
116
  // Viewport Culling Algorithm:
105
117
  // 1. Calculate visible depth range based on scroll position and container height
106
118
  // 2. Add 5-row buffer above/below for smooth scrolling experience
107
- const startDepth = Math.max(0, Math.floor(scrollTop / RowHeight) - 5);
108
- const endDepth = Math.min(
109
- effectiveDepth,
110
- Math.ceil((scrollTop + containerHeight) / RowHeight) + 5
119
+ // 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.
120
+
121
+ const BUFFER = 15; // Buffer for smoother scrolling
122
+
123
+ const visibleStartDepth = Math.max(0, Math.floor(scrollTop / RowHeight) - BUFFER);
124
+ const visibleDepths = Math.ceil(containerHeight / RowHeight);
125
+ const visibleEndDepth = Math.min(effectiveDepth, visibleStartDepth + visibleDepths + BUFFER);
126
+
127
+ // Reset range if table changed (new data loaded) as this is new data
128
+ if (renderedRangeRef.current.table !== table) {
129
+ renderedRangeRef.current = {
130
+ minDepth: Infinity,
131
+ maxDepth: -Infinity,
132
+ table: table,
133
+ };
134
+ }
135
+
136
+ // Expand the rendered range (never shrink when scrolling up/down)
137
+ renderedRangeRef.current.minDepth = Math.min(
138
+ renderedRangeRef.current.minDepth,
139
+ visibleStartDepth
111
140
  );
141
+ renderedRangeRef.current.maxDepth = Math.max(
142
+ renderedRangeRef.current.maxDepth,
143
+ visibleEndDepth
144
+ );
145
+
146
+ const startDepth = renderedRangeRef.current.minDepth;
147
+ const endDepth = renderedRangeRef.current.maxDepth;
112
148
 
113
149
  const cumulativeColumn = table.getChild(FIELD_CUMULATIVE);
114
150
  const valueOffsetColumn = table.getChild(FIELD_VALUE_OFFSET);
@@ -29,7 +29,7 @@ interface MetricsGraphSectionProps {
29
29
  querySelection: QuerySelection;
30
30
  profileSelection: ProfileSelection | null;
31
31
  comparing: boolean;
32
- sumBy: string[] | null;
32
+ sumBy: string[] | undefined;
33
33
  defaultSumByLoading: boolean;
34
34
  queryClient: QueryServiceClient;
35
35
  queryExpressionString: string;
@@ -170,7 +170,7 @@ export function MetricsGraphSection({
170
170
  to={querySelection.to}
171
171
  profile={profileSelection}
172
172
  comparing={comparing}
173
- sumBy={querySelection.sumBy ?? sumBy ?? []}
173
+ sumBy={sumBy ?? []}
174
174
  sumByLoading={defaultSumByLoading}
175
175
  setTimeRange={handleTimeRangeChange}
176
176
  addLabelMatcher={addLabelMatcher}
@@ -209,10 +209,6 @@ const ProfileSelector = ({
209
209
  const currentTo = timeRangeSelection.getToMs(true);
210
210
  const currentRangeKey = timeRangeSelection.getRangeKey();
211
211
  // Commit with refreshed time range
212
- console.log(
213
- '[draftExpression] setQueryExpression: committing with refreshed time range:',
214
- draftSelection.expression
215
- );
216
212
  commitDraft({
217
213
  from: currentFrom,
218
214
  to: currentTo,
@@ -332,7 +328,7 @@ const ProfileSelector = ({
332
328
  querySelection={querySelection}
333
329
  profileSelection={profileSelection}
334
330
  comparing={comparing}
335
- sumBy={querySelection.sumBy ?? []}
331
+ sumBy={querySelection.sumBy}
336
332
  defaultSumByLoading={sumByLoading}
337
333
  queryClient={queryClient}
338
334
  queryExpressionString={queryExpressionString}
@@ -18,6 +18,8 @@ import {useProfileFilters} from '../components/ProfileFilters/useProfileFilters'
18
18
  export const useResetStateOnProfileTypeChange = (): (() => void) => {
19
19
  const [groupBy, setGroupBy] = useURLState('group_by');
20
20
  const [curPath, setCurPath] = useURLState('cur_path');
21
+ const [sumByA, setSumByA] = useURLState('sum_by_a');
22
+ const [sumByB, setSumByB] = useURLState('sum_by_b');
21
23
  const {resetFilters} = useProfileFilters();
22
24
  const [sandwichFunctionName, setSandwichFunctionName] = useURLState('sandwich_function_name');
23
25
  const batchUpdates = useURLStateBatch();
@@ -34,6 +36,12 @@ export const useResetStateOnProfileTypeChange = (): (() => void) => {
34
36
  if (sandwichFunctionName !== undefined) {
35
37
  setSandwichFunctionName(undefined);
36
38
  }
39
+ if (sumByA !== undefined) {
40
+ setSumByA(undefined);
41
+ }
42
+ if (sumByB !== undefined) {
43
+ setSumByB(undefined);
44
+ }
37
45
 
38
46
  resetFilters();
39
47
  });
@@ -11,7 +11,7 @@
11
11
  // See the License for the specific language governing permissions and
12
12
  // limitations under the License.
13
13
 
14
- import React, {useCallback, useEffect, useRef, useState} from 'react';
14
+ import React, {Fragment, useCallback, useEffect, useRef, useState} from 'react';
15
15
 
16
16
  import {Icon} from '@iconify/react';
17
17
  import cx from 'classnames';
@@ -350,7 +350,7 @@ const CustomSelect: React.FC<CustomSelectProps & Record<string, any>> = ({
350
350
  </div>
351
351
  ) : (
352
352
  groupedFilteredItems.map(group => (
353
- <>
353
+ <Fragment key={group.type}>
354
354
  {groupedFilteredItems.length > 1 &&
355
355
  groupedFilteredItems.every(g => g.type !== '') &&
356
356
  group.type !== '' ? (
@@ -369,7 +369,7 @@ const CustomSelect: React.FC<CustomSelectProps & Record<string, any>> = ({
369
369
  handleSelection={handleSelection}
370
370
  />
371
371
  ))}
372
- </>
372
+ </Fragment>
373
373
  ))
374
374
  )}
375
375
  </div>