Skip to content

Commit df882e5

Browse files
committed
Improved performance more by splitting state out for payload
1 parent 1724c6a commit df882e5

File tree

7 files changed

+109
-76
lines changed

7 files changed

+109
-76
lines changed

apps/webapp/app/components/code/QueryResultsChart.tsx

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -10,7 +10,7 @@ import { aggregateValues } from "../primitives/charts/aggregation";
1010
import { getRunStatusHexColor } from "~/components/runs/v3/TaskRunStatus";
1111
import { getSeriesColor } from "./chartColors";
1212

13-
const MAX_SERIES = 30;
13+
const MAX_SERIES = 50;
1414
const MAX_SVG_ELEMENT_BUDGET = 6_000;
1515
const MIN_DATA_POINTS = 100;
1616
const MAX_DATA_POINTS = 500;

apps/webapp/app/components/primitives/charts/ChartBar.tsx

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -65,7 +65,7 @@ export function ChartBarRenderer({
6565
width,
6666
height,
6767
}: ChartBarRendererProps) {
68-
const { config, data, dataKey, dataKeys, visibleSeries, state, highlight, zoom, showLegend } = useChartContext();
68+
const { config, data, dataKey, dataKeys, visibleSeries, state, highlight, setActivePayload, zoom, showLegend } = useChartContext();
6969
const hasNoData = useHasNoData();
7070
const zoomHandlers = useZoomHandlers();
7171
const enableZoom = zoom !== null;
@@ -114,7 +114,7 @@ export function ChartBarRenderer({
114114
onMouseMove={(e: any) => {
115115
zoomHandlers.onMouseMove?.(e);
116116
if (e?.activePayload?.length) {
117-
highlight.setActivePayload(e.activePayload, e.activeTooltipIndex);
117+
setActivePayload(e.activePayload, e.activeTooltipIndex);
118118
highlight.setTooltipActive(true);
119119
} else {
120120
highlight.setTooltipActive(false);

apps/webapp/app/components/primitives/charts/ChartContext.tsx

Lines changed: 53 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,4 @@
1-
import React, { createContext, useContext, useMemo } from "react";
1+
import React, { createContext, useCallback, useContext, useMemo, useRef, useState } from "react";
22
import type { ChartConfig, ChartState } from "./Chart";
33
import { useHighlightState, type UseHighlightStateReturn } from "./hooks/useHighlightState";
44
import {
@@ -27,9 +27,12 @@ export type ChartContextValue = {
2727
/** Function to format the x-axis label (used in legend, tooltips, etc.) */
2828
labelFormatter?: LabelFormatter;
2929

30-
// Highlight state
30+
// Highlight state (does NOT include activePayload — see PayloadContext)
3131
highlight: UseHighlightStateReturn;
3232

33+
/** Update the active payload for the legend. Pass tooltipIndex to skip redundant updates. */
34+
setActivePayload: (payload: any[] | null, tooltipIndex?: number | null) => void;
35+
3336
// Zoom state (only present when zoom is enabled)
3437
zoom: UseZoomSelectionReturn | null;
3538

@@ -42,6 +45,12 @@ export type ChartContextValue = {
4245

4346
const ChartCompoundContext = createContext<ChartContextValue | null>(null);
4447

48+
/**
49+
* Separate context for activePayload so that frequent payload updates
50+
* only re-render the legend, not the entire chart (bars, lines, etc.).
51+
*/
52+
const PayloadContext = createContext<any[] | null>(null);
53+
4554
export function useChartContext(): ChartContextValue {
4655
const context = useContext(ChartCompoundContext);
4756
if (!context) {
@@ -50,6 +59,11 @@ export function useChartContext(): ChartContextValue {
5059
return context;
5160
}
5261

62+
/** Read the active payload (only re-renders when payload changes). */
63+
export function useActivePayload(): any[] | null {
64+
return useContext(PayloadContext);
65+
}
66+
5367
export type ChartProviderProps = {
5468
config: ChartConfig;
5569
data: any[];
@@ -86,6 +100,35 @@ export function ChartProvider({
86100
const highlight = useHighlightState();
87101
const zoomState = useZoomSelection();
88102

103+
// activePayload lives in its own state + context so updates don't re-render bars
104+
const [activePayload, setActivePayloadRaw] = useState<any[] | null>(null);
105+
const activeTooltipIndexRef = useRef<number | null>(null);
106+
107+
const setActivePayload = useCallback(
108+
(payload: any[] | null, tooltipIndex?: number | null) => {
109+
const idx = tooltipIndex ?? null;
110+
if (idx !== null && idx === activeTooltipIndexRef.current) {
111+
return;
112+
}
113+
activeTooltipIndexRef.current = idx;
114+
setActivePayloadRaw(payload);
115+
},
116+
[]
117+
);
118+
119+
// Reset the tooltip index ref when highlight resets (mouse leaves chart)
120+
const originalReset = highlight.reset;
121+
const resetWithPayload = useCallback(() => {
122+
activeTooltipIndexRef.current = null;
123+
setActivePayloadRaw(null);
124+
originalReset();
125+
}, [originalReset]);
126+
127+
const highlightWithReset = useMemo(
128+
() => ({ ...highlight, reset: resetWithPayload }),
129+
[highlight, resetWithPayload]
130+
);
131+
89132
// Compute series keys (use provided series or derive from config)
90133
const dataKeys = useMemo(
91134
() => series ?? Object.keys(config).filter((k) => k !== dataKey),
@@ -106,13 +149,18 @@ export function ChartProvider({
106149
visibleSeries,
107150
state,
108151
labelFormatter,
109-
highlight,
152+
highlight: highlightWithReset,
153+
setActivePayload,
110154
zoom: enableZoom ? zoomState : null,
111155
onZoomChange: enableZoom ? onZoomChange : undefined,
112156
showLegend,
113157
}),
114-
[config, data, dataKey, dataKeys, visibleSeries, state, labelFormatter, highlight, zoomState, enableZoom, onZoomChange, showLegend]
158+
[config, data, dataKey, dataKeys, visibleSeries, state, labelFormatter, highlightWithReset, setActivePayload, zoomState, enableZoom, onZoomChange, showLegend]
115159
);
116160

117-
return <ChartCompoundContext.Provider value={value}>{children}</ChartCompoundContext.Provider>;
161+
return (
162+
<ChartCompoundContext.Provider value={value}>
163+
<PayloadContext.Provider value={activePayload}>{children}</PayloadContext.Provider>
164+
</ChartCompoundContext.Provider>
165+
);
118166
}

apps/webapp/app/components/primitives/charts/ChartLegendCompound.tsx

Lines changed: 12 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
import React, { useMemo } from "react";
22
import type { AggregationType } from "~/components/metrics/QueryWidget";
3-
import { useChartContext } from "./ChartContext";
3+
import { useActivePayload, useChartContext } from "./ChartContext";
44
import { useSeriesTotal } from "./ChartRoot";
55
import { aggregateValues } from "./aggregation";
66
import { cn } from "~/utils/cn";
@@ -54,6 +54,7 @@ export function ChartLegendCompound({
5454
scrollable = false,
5555
}: ChartLegendCompoundProps) {
5656
const { config, dataKey, dataKeys, highlight, labelFormatter } = useChartContext();
57+
const activePayload = useActivePayload();
5758
const totals = useSeriesTotal(aggregation);
5859

5960
// Derive the effective label from the aggregation type when no explicit label is provided
@@ -71,10 +72,10 @@ export function ChartLegendCompound({
7172

7273
// Calculate current total based on hover state (null when hovering a gap-filled point)
7374
const currentTotal = useMemo((): number | null => {
74-
if (!highlight.activePayload?.length) return grandTotal;
75+
if (!activePayload?.length) return grandTotal;
7576

7677
// Collect all series values from the hovered data point, preserving nulls
77-
const rawValues = highlight.activePayload
78+
const rawValues = activePayload
7879
.filter((item) => item.value !== undefined && dataKeys.includes(item.dataKey as string))
7980
.map((item) => item.value);
8081

@@ -91,29 +92,29 @@ export function ChartLegendCompound({
9192
return values.reduce((a, b) => a + b, 0);
9293
}
9394
return aggregateValues(values, aggregation);
94-
}, [highlight.activePayload, grandTotal, dataKeys, aggregation]);
95+
}, [activePayload, grandTotal, dataKeys, aggregation]);
9596

9697
// Get the label for the total row - x-axis value when hovering, effectiveTotalLabel otherwise
9798
const currentTotalLabel = useMemo(() => {
98-
if (!highlight.activePayload?.length) return effectiveTotalLabel;
99+
if (!activePayload?.length) return effectiveTotalLabel;
99100

100101
// Get the x-axis label from the payload's original data
101-
const firstPayloadItem = highlight.activePayload[0];
102+
const firstPayloadItem = activePayload[0];
102103
const xAxisValue = firstPayloadItem?.payload?.[dataKey];
103104

104105
if (xAxisValue === undefined) return effectiveTotalLabel;
105106

106107
// Apply the formatter if provided, otherwise just stringify the value
107108
const stringValue = String(xAxisValue);
108109
return labelFormatter ? labelFormatter(stringValue) : stringValue;
109-
}, [highlight.activePayload, dataKey, effectiveTotalLabel, labelFormatter]);
110+
}, [activePayload, dataKey, effectiveTotalLabel, labelFormatter]);
110111

111112
// Get current data for the legend based on hover state (values may be null for gap-filled points)
112113
const currentData = useMemo((): Record<string, number | null> => {
113-
if (!highlight.activePayload?.length) return totals;
114+
if (!activePayload?.length) return totals;
114115

115116
// If we have activePayload data from hovering over a bar/line
116-
const hoverData = highlight.activePayload.reduce(
117+
const hoverData = activePayload.reduce(
117118
(acc, item) => {
118119
if (item.dataKey && item.value !== undefined) {
119120
// Preserve null for gap-filled points instead of coercing to 0
@@ -129,7 +130,7 @@ export function ChartLegendCompound({
129130
...totals,
130131
...hoverData,
131132
};
132-
}, [highlight.activePayload, totals]);
133+
}, [activePayload, totals]);
133134

134135
// Prepare legend items with capped display
135136
const legendItems = useMemo(() => {
@@ -163,7 +164,7 @@ export function ChartLegendCompound({
163164
return null;
164165
}
165166

166-
const isHovering = (highlight.activePayload?.length ?? 0) > 0;
167+
const isHovering = (activePayload?.length ?? 0) > 0;
167168

168169
return (
169170
<div

apps/webapp/app/components/primitives/charts/ChartLine.tsx

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -78,7 +78,7 @@ export function ChartLineRenderer({
7878
width,
7979
height,
8080
}: ChartLineRendererProps) {
81-
const { config, data, dataKey, dataKeys, visibleSeries, state, highlight, showLegend } = useChartContext();
81+
const { config, data, dataKey, dataKeys, visibleSeries, state, highlight, setActivePayload, showLegend } = useChartContext();
8282
const hasNoData = useHasNoData();
8383

8484
// Render loading/error states
@@ -143,7 +143,7 @@ export function ChartLineRenderer({
143143
}}
144144
onMouseMove={(e: any) => {
145145
if (e?.activePayload?.length) {
146-
highlight.setActivePayload(e.activePayload, e.activeTooltipIndex);
146+
setActivePayload(e.activePayload, e.activeTooltipIndex);
147147
highlight.setTooltipActive(true);
148148
} else {
149149
highlight.setTooltipActive(false);
@@ -191,7 +191,7 @@ export function ChartLineRenderer({
191191
}}
192192
onMouseMove={(e: any) => {
193193
if (e?.activePayload?.length) {
194-
highlight.setActivePayload(e.activePayload, e.activeTooltipIndex);
194+
setActivePayload(e.activePayload, e.activeTooltipIndex);
195195
highlight.setTooltipActive(true);
196196
} else {
197197
highlight.setTooltipActive(false);

apps/webapp/app/components/primitives/charts/hooks/useHighlightState.ts

Lines changed: 24 additions & 43 deletions
Original file line numberDiff line numberDiff line change
@@ -1,23 +1,19 @@
1-
import { useCallback, useRef, useState } from "react";
1+
import { useCallback, useMemo, useState } from "react";
22

33
export type HighlightState = {
44
/** The currently highlighted series key (e.g., "completed", "failed") */
55
activeBarKey: string | null;
66
/** The index of the specific data point being hovered (null when hovering legend) */
77
activeDataPointIndex: number | null;
8-
/** The payload data from the hovered element */
9-
activePayload: any[] | null;
108
/** Whether the tooltip is currently active */
119
tooltipActive: boolean;
1210
};
1311

1412
export type HighlightActions = {
1513
/** Set the hovered bar (specific data point) */
16-
setHoveredBar: (key: string, index: number, payload?: any[]) => void;
14+
setHoveredBar: (key: string, index: number) => void;
1715
/** Set the hovered legend item (highlights all bars of that type) */
1816
setHoveredLegendItem: (key: string) => void;
19-
/** Set the active payload (for tooltip data). Pass tooltipIndex to skip redundant updates. */
20-
setActivePayload: (payload: any[] | null, tooltipIndex?: number | null) => void;
2117
/** Set tooltip active state */
2218
setTooltipActive: (active: boolean) => void;
2319
/** Reset all highlight state */
@@ -29,70 +25,55 @@ export type UseHighlightStateReturn = HighlightState & HighlightActions;
2925
const initialState: HighlightState = {
3026
activeBarKey: null,
3127
activeDataPointIndex: null,
32-
activePayload: null,
3328
tooltipActive: false,
3429
};
3530

3631
/**
3732
* Hook to manage highlight state for chart elements.
3833
* Handles both bar hover (specific data point) and legend hover (all bars of a type).
34+
*
35+
* activePayload is intentionally NOT managed here — it lives in a separate context
36+
* so that payload updates (frequent during mouse movement) don't cause bar re-renders.
3937
*/
4038
export function useHighlightState(): UseHighlightStateReturn {
4139
const [state, setState] = useState<HighlightState>(initialState);
42-
const activeTooltipIndexRef = useRef<number | null>(null);
4340

44-
const setHoveredBar = useCallback((key: string, index: number, payload?: any[]) => {
41+
const setHoveredBar = useCallback((key: string, index: number) => {
4542
setState({
4643
activeBarKey: key,
4744
activeDataPointIndex: index,
48-
activePayload: payload ?? null,
4945
tooltipActive: true,
5046
});
5147
}, []);
5248

5349
const setHoveredLegendItem = useCallback((key: string) => {
54-
setState((prev) => ({
55-
...prev,
56-
activeBarKey: key,
57-
activeDataPointIndex: null,
58-
}));
59-
}, []);
60-
61-
const setActivePayload = useCallback((payload: any[] | null, tooltipIndex?: number | null) => {
62-
const idx = tooltipIndex ?? null;
63-
if (idx !== null && idx === activeTooltipIndexRef.current) {
64-
console.log("Tooltip index is the same, skipping update", activeTooltipIndexRef.current);
65-
return;
66-
}
67-
68-
console.log("Tooltip index changed", idx);
69-
activeTooltipIndexRef.current = idx;
70-
setState((prev) => ({
71-
...prev,
72-
activePayload: payload,
73-
}));
50+
setState((prev) => {
51+
if (prev.activeBarKey === key && prev.activeDataPointIndex === null) return prev;
52+
return { ...prev, activeBarKey: key, activeDataPointIndex: null };
53+
});
7454
}, []);
7555

7656
const setTooltipActive = useCallback((active: boolean) => {
77-
setState((prev) => ({
78-
...prev,
79-
tooltipActive: active,
80-
}));
57+
setState((prev) => {
58+
if (prev.tooltipActive === active) return prev;
59+
return { ...prev, tooltipActive: active };
60+
});
8161
}, []);
8262

8363
const reset = useCallback(() => {
84-
activeTooltipIndexRef.current = null;
8564
setState(initialState);
8665
}, []);
8766

88-
return {
89-
...state,
90-
setHoveredBar,
91-
setHoveredLegendItem,
92-
setActivePayload,
93-
setTooltipActive,
94-
reset,
95-
};
67+
return useMemo(
68+
() => ({
69+
...state,
70+
setHoveredBar,
71+
setHoveredLegendItem,
72+
setTooltipActive,
73+
reset,
74+
}),
75+
[state, setHoveredBar, setHoveredLegendItem, setTooltipActive, reset]
76+
);
9677
}
9778

9879
/**

apps/webapp/app/components/primitives/charts/hooks/useZoomSelection.ts

Lines changed: 14 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,4 @@
1-
import { useCallback, useRef, useState } from "react";
1+
import { useCallback, useMemo, useRef, useState } from "react";
22

33
export type ZoomRange = {
44
start: string;
@@ -175,14 +175,17 @@ export function useZoomSelection(): UseZoomSelectionReturn {
175175
setState(initialState);
176176
}, []);
177177

178-
return {
179-
...state,
180-
startSelection,
181-
updateSelection,
182-
finishSelection,
183-
cancelSelection,
184-
toggleInspectionLine,
185-
clearInspectionLine,
186-
reset,
187-
};
178+
return useMemo(
179+
() => ({
180+
...state,
181+
startSelection,
182+
updateSelection,
183+
finishSelection,
184+
cancelSelection,
185+
toggleInspectionLine,
186+
clearInspectionLine,
187+
reset,
188+
}),
189+
[state, startSelection, updateSelection, finishSelection, cancelSelection, toggleInspectionLine, clearInspectionLine, reset]
190+
);
188191
}

0 commit comments

Comments
 (0)