@parca/profile 0.19.73 → 0.19.74

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 (71) hide show
  1. package/CHANGELOG.md +4 -0
  2. package/dist/MatchersInput/index.d.ts.map +1 -1
  3. package/dist/MatchersInput/index.js +4 -2
  4. package/dist/ProfileExplorer/ProfileExplorerCompare.d.ts +1 -12
  5. package/dist/ProfileExplorer/ProfileExplorerCompare.d.ts.map +1 -1
  6. package/dist/ProfileExplorer/ProfileExplorerCompare.js +52 -11
  7. package/dist/ProfileExplorer/ProfileExplorerSingle.d.ts +1 -7
  8. package/dist/ProfileExplorer/ProfileExplorerSingle.d.ts.map +1 -1
  9. package/dist/ProfileExplorer/ProfileExplorerSingle.js +4 -2
  10. package/dist/ProfileExplorer/index.d.ts +1 -4
  11. package/dist/ProfileExplorer/index.d.ts.map +1 -1
  12. package/dist/ProfileExplorer/index.js +11 -225
  13. package/dist/ProfileMetricsGraph/index.d.ts +1 -1
  14. package/dist/ProfileMetricsGraph/index.d.ts.map +1 -1
  15. package/dist/ProfileMetricsGraph/index.js +16 -20
  16. package/dist/ProfileSelector/MetricsGraphSection.d.ts +3 -3
  17. package/dist/ProfileSelector/MetricsGraphSection.d.ts.map +1 -1
  18. package/dist/ProfileSelector/MetricsGraphSection.js +10 -6
  19. package/dist/ProfileSelector/index.d.ts +2 -7
  20. package/dist/ProfileSelector/index.d.ts.map +1 -1
  21. package/dist/ProfileSelector/index.js +40 -46
  22. package/dist/ProfileSelector/useAutoQuerySelector.d.ts.map +1 -1
  23. package/dist/ProfileSelector/useAutoQuerySelector.js +19 -4
  24. package/dist/ProfileTypeSelector/index.d.ts.map +1 -1
  25. package/dist/ProfileTypeSelector/index.js +1 -1
  26. package/dist/ProfileView/components/ViewSelector/index.d.ts.map +1 -1
  27. package/dist/ProfileView/components/ViewSelector/index.js +10 -4
  28. package/dist/ProfileView/hooks/useResetStateOnProfileTypeChange.d.ts.map +1 -1
  29. package/dist/ProfileView/hooks/useResetStateOnProfileTypeChange.js +4 -2
  30. package/dist/ProfileView/hooks/useVisualizationState.d.ts.map +1 -1
  31. package/dist/ProfileView/hooks/useVisualizationState.js +20 -13
  32. package/dist/Table/MoreDropdown.d.ts.map +1 -1
  33. package/dist/Table/MoreDropdown.js +7 -3
  34. package/dist/Table/TableContextMenu.d.ts.map +1 -1
  35. package/dist/Table/TableContextMenu.js +9 -5
  36. package/dist/hooks/useCompareModeMeta.d.ts +10 -0
  37. package/dist/hooks/useCompareModeMeta.d.ts.map +1 -0
  38. package/dist/hooks/useCompareModeMeta.js +113 -0
  39. package/dist/hooks/useQueryState.d.ts +32 -0
  40. package/dist/hooks/useQueryState.d.ts.map +1 -0
  41. package/dist/hooks/useQueryState.js +285 -0
  42. package/dist/hooks/useQueryState.test.d.ts +2 -0
  43. package/dist/hooks/useQueryState.test.d.ts.map +1 -0
  44. package/dist/hooks/useQueryState.test.js +910 -0
  45. package/dist/index.d.ts +4 -5
  46. package/dist/index.d.ts.map +1 -1
  47. package/dist/index.js +6 -3
  48. package/dist/useSumBy.d.ts +7 -0
  49. package/dist/useSumBy.d.ts.map +1 -1
  50. package/dist/useSumBy.js +31 -6
  51. package/package.json +6 -6
  52. package/src/MatchersInput/index.tsx +4 -2
  53. package/src/ProfileExplorer/ProfileExplorerCompare.tsx +64 -46
  54. package/src/ProfileExplorer/ProfileExplorerSingle.tsx +7 -19
  55. package/src/ProfileExplorer/index.tsx +11 -339
  56. package/src/ProfileMetricsGraph/index.tsx +16 -20
  57. package/src/ProfileSelector/MetricsGraphSection.tsx +14 -10
  58. package/src/ProfileSelector/index.tsx +65 -83
  59. package/src/ProfileSelector/useAutoQuerySelector.ts +23 -5
  60. package/src/ProfileTypeSelector/index.tsx +3 -1
  61. package/src/ProfileView/components/ViewSelector/index.tsx +9 -4
  62. package/src/ProfileView/hooks/useResetStateOnProfileTypeChange.ts +4 -2
  63. package/src/ProfileView/hooks/useVisualizationState.ts +25 -12
  64. package/src/Table/MoreDropdown.tsx +7 -3
  65. package/src/Table/TableContextMenu.tsx +9 -5
  66. package/src/hooks/useCompareModeMeta.ts +131 -0
  67. package/src/hooks/useQueryState.test.tsx +1202 -0
  68. package/src/hooks/useQueryState.ts +414 -0
  69. package/src/index.tsx +9 -11
  70. package/src/useSumBy.ts +62 -7
  71. package/src/ProfileExplorer/index.test.ts +0 -97
@@ -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 {Dispatch, SetStateAction, useEffect, useMemo, useRef, useState} from 'react';
14
+ import {Dispatch, SetStateAction, useCallback, useEffect, useMemo, useRef, useState} from 'react';
15
15
 
16
16
  import {RpcError} from '@protobuf-ts/runtime-rpc';
17
17
 
@@ -28,12 +28,11 @@ import {Query} from '@parca/parser';
28
28
  import {TEST_IDS, testId} from '@parca/test-utils';
29
29
  import {millisToProtoTimestamp, type NavigateFunction} from '@parca/utilities';
30
30
 
31
- import {ProfileSelection} from '..';
32
31
  import {useLabelNames} from '../MatchersInput/index';
33
32
  import {useMetricsGraphDimensions} from '../MetricsGraph/useMetricsGraphDimensions';
34
33
  import {UtilizationLabelsProvider} from '../contexts/UtilizationLabelsContext';
34
+ import {useQueryState} from '../hooks/useQueryState';
35
35
  import useGrpcQuery from '../useGrpcQuery';
36
- import {useDefaultSumBy, useSumBySelection} from '../useSumBy';
37
36
  import {MetricsGraphSection} from './MetricsGraphSection';
38
37
  import {QueryControls} from './QueryControls';
39
38
  import {useAutoQuerySelector} from './useAutoQuerySelector';
@@ -79,16 +78,12 @@ export interface UtilizationLabels {
79
78
 
80
79
  interface ProfileSelectorProps extends ProfileSelectorFeatures {
81
80
  queryClient: QueryServiceClient;
82
- querySelection: QuerySelection;
83
- selectProfile: (source: ProfileSelection) => void;
84
- selectQuery: (query: QuerySelection) => void;
85
81
  closeProfile: () => void;
86
82
  enforcedProfileName: string;
87
- profileSelection: ProfileSelection | null;
88
83
  comparing: boolean;
89
84
  navigateTo: NavigateFunction;
90
85
  setDisplayHideMetricsGraphButton?: Dispatch<SetStateAction<boolean>>;
91
- suffix?: string;
86
+ suffix?: '_a' | '_b'; // For comparison mode
92
87
  utilizationMetrics?: Array<{
93
88
  name: string;
94
89
  humanReadableName: string;
@@ -135,12 +130,8 @@ export const useProfileTypes = (
135
130
 
136
131
  const ProfileSelector = ({
137
132
  queryClient,
138
- querySelection,
139
- selectProfile,
140
- selectQuery,
141
133
  closeProfile,
142
134
  enforcedProfileName,
143
- profileSelection,
144
135
  comparing,
145
136
  navigateTo,
146
137
  showMetricsGraph = true,
@@ -148,6 +139,7 @@ const ProfileSelector = ({
148
139
  showProfileTypeSelector = true,
149
140
  disableExplorativeQuerying = false,
150
141
  setDisplayHideMetricsGraphButton,
142
+ suffix,
151
143
  utilizationMetrics,
152
144
  utilizationMetricsLoading,
153
145
  utilizationLabels,
@@ -157,24 +149,45 @@ const ProfileSelector = ({
157
149
  const {viewComponent} = useParcaContext();
158
150
  const [queryBrowserMode, setQueryBrowserMode] = useURLState('query_browser_mode');
159
151
 
152
+ // Use the new useQueryState hook - reads directly from URL params
153
+ const {
154
+ querySelection,
155
+ draftSelection,
156
+ setDraftExpression,
157
+ setDraftTimeRange,
158
+ setDraftSumBy,
159
+ setDraftProfileName,
160
+ setDraftMatchers,
161
+ commitDraft,
162
+ profileSelection,
163
+ setProfileSelection,
164
+ sumByLoading,
165
+ } = useQueryState({suffix});
166
+
167
+ // Use draft state for local state instead of committed state
160
168
  const [timeRangeSelection, setTimeRangeSelection] = useState(
161
- DateTimeRange.fromRangeKey(querySelection.timeSelection, querySelection.from, querySelection.to)
169
+ DateTimeRange.fromRangeKey(draftSelection.timeSelection, draftSelection.from, draftSelection.to)
162
170
  );
163
171
 
164
- const [queryExpressionString, setQueryExpressionString] = useState(querySelection.expression);
172
+ const [queryExpressionString, setQueryExpressionString] = useState(draftSelection.expression);
165
173
 
166
174
  const [advancedModeForQueryBrowser, setAdvancedModeForQueryBrowser] = useState(
167
175
  queryBrowserMode === 'advanced'
168
176
  );
169
177
 
178
+ // Handler to update draft when time range changes
179
+ const handleTimeRangeChange = useCallback(
180
+ (range: DateTimeRange) => {
181
+ setTimeRangeSelection(range);
182
+ setDraftTimeRange(range.getFromMs(), range.getToMs(), range.getRangeKey());
183
+ },
184
+ [setDraftTimeRange]
185
+ );
186
+
170
187
  const profileType = useMemo(() => {
171
188
  return Query.parse(queryExpressionString).profileType();
172
189
  }, [queryExpressionString]);
173
190
 
174
- const selectedProfileType = useMemo(() => {
175
- return Query.parse(querySelection.expression).profileType();
176
- }, [querySelection.expression]);
177
-
178
191
  const from = timeRangeSelection.getFromMs();
179
192
  const to = timeRangeSelection.getToMs();
180
193
 
@@ -184,39 +197,12 @@ const ProfileSelector = ({
184
197
  error,
185
198
  } = useProfileTypes(queryClient, from, to);
186
199
 
187
- const {
188
- loading: labelNamesLoading,
189
- result,
190
- refetch,
191
- } = useLabelNames(queryClient, profileType.toString(), from, to);
192
- const {loading: selectedLabelNamesLoading, result: selectedLabelNamesResult} = useLabelNames(
193
- queryClient,
194
- selectedProfileType.toString(),
195
- from,
196
- to
197
- );
200
+ const {result, refetch} = useLabelNames(queryClient, profileType.toString(), from, to);
198
201
 
199
202
  const labels = useMemo(() => {
200
203
  return result.response?.labelNames === undefined ? [] : result.response.labelNames;
201
204
  }, [result]);
202
205
 
203
- const selectedLabels = useMemo(() => {
204
- return selectedLabelNamesResult.response?.labelNames === undefined
205
- ? []
206
- : selectedLabelNamesResult.response.labelNames;
207
- }, [selectedLabelNamesResult]);
208
-
209
- const [sumBySelection, setUserSumBySelection, {isLoading: sumBySelectionLoading}] =
210
- useSumBySelection(profileType, labelNamesLoading, labels, {
211
- defaultValue: querySelection.sumBy,
212
- });
213
-
214
- const {defaultSumBy, isLoading: defaultSumByLoading} = useDefaultSumBy(
215
- selectedProfileType,
216
- selectedLabelNamesLoading,
217
- selectedLabels
218
- );
219
-
220
206
  useEffect(() => {
221
207
  if (enforcedProfileName !== '') {
222
208
  const [q, changed] = Query.parse(querySelection.expression).setProfileName(
@@ -240,41 +226,37 @@ const ProfileSelector = ({
240
226
  enforcedProfileName !== '' ? enforcedProfileNameQuery() : Query.parse(queryExpressionString);
241
227
  const selectedProfileName = query.profileName();
242
228
 
243
- const setNewQueryExpression = (expr: string, updateTs = false): void => {
244
- const query = enforcedProfileName !== '' ? enforcedProfileNameQuery() : Query.parse(expr);
245
- const delta = query.profileType().delta;
246
- const from = timeRangeSelection.getFromMs(updateTs);
247
- const to = timeRangeSelection.getToMs(updateTs);
248
- const mergeParams = delta
249
- ? {
250
- mergeFrom: (BigInt(from) * 1_000_000n).toString(),
251
- mergeTo: (BigInt(to) * 1_000_000n).toString(),
252
- }
253
- : {};
254
-
255
- selectQuery({
256
- expression: expr,
257
- from,
258
- to,
259
- timeSelection: timeRangeSelection.getRangeKey(),
260
- sumBy: sumBySelection,
261
- ...mergeParams,
262
- });
263
- };
264
-
265
229
  const setQueryExpression = (updateTs = false): void => {
266
- setNewQueryExpression(query.toString(), updateTs);
230
+ // When updateTs is true, re-evaluate the time range to current values
231
+ if (updateTs) {
232
+ // Force re-evaluation of time range (important for relative ranges like "last 15 minutes")
233
+ const currentFrom = timeRangeSelection.getFromMs(true);
234
+ const currentTo = timeRangeSelection.getToMs(true);
235
+ const currentRangeKey = timeRangeSelection.getRangeKey();
236
+ // Commit with refreshed time range
237
+ commitDraft({
238
+ from: currentFrom,
239
+ to: currentTo,
240
+ timeSelection: currentRangeKey,
241
+ });
242
+ } else {
243
+ // Commit the draft with existing values
244
+ commitDraft();
245
+ }
267
246
  };
268
247
 
269
248
  const setMatchersString = (matchers: string): void => {
270
- const newExpressionString = `${selectedProfileName}{${matchers}}`;
271
- setQueryExpressionString(newExpressionString);
249
+ // Update draft state only
250
+ setDraftMatchers(matchers);
251
+ setQueryExpressionString(`${selectedProfileName}{${matchers}}`);
272
252
  };
273
253
 
274
254
  const setProfileName = (profileName: string | undefined): void => {
275
255
  if (profileName === undefined) {
276
256
  return;
277
257
  }
258
+ // Update draft state only
259
+ setDraftProfileName(profileName);
278
260
  const [newQuery, changed] = query.setProfileName(profileName);
279
261
  if (changed) {
280
262
  const q = newQuery.toString();
@@ -293,9 +275,9 @@ const ProfileSelector = ({
293
275
  profileTypesData,
294
276
  setProfileName,
295
277
  setQueryExpression,
296
- querySelection: {...querySelection, sumBy: sumBySelection},
278
+ querySelection,
297
279
  navigateTo,
298
- loading: sumBySelectionLoading,
280
+ loading: sumByLoading,
299
281
  });
300
282
 
301
283
  const searchDisabled =
@@ -323,7 +305,7 @@ const ProfileSelector = ({
323
305
  query={query}
324
306
  queryBrowserRef={queryBrowserRef}
325
307
  timeRangeSelection={timeRangeSelection}
326
- setTimeRangeSelection={setTimeRangeSelection}
308
+ setTimeRangeSelection={handleTimeRangeChange}
327
309
  searchDisabled={searchDisabled}
328
310
  queryBrowserMode={queryBrowserMode as string}
329
311
  setQueryBrowserMode={setQueryBrowserMode}
@@ -332,9 +314,9 @@ const ProfileSelector = ({
332
314
  queryClient={queryClient}
333
315
  sumByRef={sumByRef}
334
316
  labels={labels}
335
- sumBySelection={sumBySelection ?? []}
336
- sumBySelectionLoading={sumBySelectionLoading}
337
- setUserSumBySelection={setUserSumBySelection}
317
+ sumBySelection={draftSelection.sumBy ?? []}
318
+ sumBySelectionLoading={sumByLoading}
319
+ setUserSumBySelection={setDraftSumBy}
338
320
  profileType={profileType}
339
321
  profileTypesError={error}
340
322
  viewComponent={viewComponent}
@@ -361,16 +343,16 @@ const ProfileSelector = ({
361
343
  querySelection={querySelection}
362
344
  profileSelection={profileSelection}
363
345
  comparing={comparing}
364
- sumBy={querySelection.sumBy ?? defaultSumBy ?? []}
365
- defaultSumByLoading={defaultSumByLoading}
346
+ sumBy={querySelection.sumBy ?? []}
347
+ defaultSumByLoading={sumByLoading}
366
348
  queryClient={queryClient}
367
349
  queryExpressionString={queryExpressionString}
368
- setTimeRangeSelection={setTimeRangeSelection}
369
- selectQuery={selectQuery}
370
- selectProfile={selectProfile}
350
+ setTimeRangeSelection={handleTimeRangeChange}
351
+ selectQuery={commitDraft}
352
+ setProfileSelection={setProfileSelection}
371
353
  query={query}
372
354
  setQueryExpression={setQueryExpression}
373
- setNewQueryExpression={setNewQueryExpression}
355
+ setNewQueryExpression={setDraftExpression}
374
356
  utilizationMetrics={utilizationMetrics}
375
357
  utilizationMetricsLoading={utilizationMetricsLoading}
376
358
  onUtilizationSeriesSelect={onUtilizationSeriesSelect}
@@ -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 {useEffect} from 'react';
14
+ import {useEffect, useRef} from 'react';
15
15
 
16
16
  import {ProfileTypesResponse} from '@parca/client';
17
17
  import {selectAutoQuery, setAutoQuery, useAppDispatch, useAppSelector} from '@parca/store';
@@ -43,15 +43,32 @@ export const useAutoQuerySelector = ({
43
43
  const autoQuery = useAppSelector(selectAutoQuery);
44
44
  const dispatch = useAppDispatch();
45
45
  const queryParams = new URLSearchParams(location.search);
46
-
47
- const comparing = queryParams.get('comparing') === 'true';
46
+ const compareA = queryParams.get('compare_a');
47
+ const compareB = queryParams.get('compare_b');
48
+ const comparing = compareA === 'true' || compareB === 'true';
48
49
  const expressionA = queryParams.get('expression_a');
50
+ const expressionB = queryParams.get('expression_b');
51
+
52
+ // Track if we've already set up compare mode to prevent infinite loops
53
+ const hasSetupCompareMode = useRef(false);
49
54
 
50
55
  useEffect(() => {
51
56
  if (loading) {
52
57
  return;
53
58
  }
54
- if (comparing && expressionA !== null && expressionA !== undefined) {
59
+
60
+ // Only run this effect if:
61
+ // 1. We're in compare mode
62
+ // 2. expressionA exists
63
+ // 3. expressionB doesn't exist yet (meaning we need to set it up)
64
+ // 4. We haven't already set it up in this session
65
+ if (
66
+ comparing &&
67
+ expressionA !== null &&
68
+ expressionA !== undefined &&
69
+ expressionB === null &&
70
+ !hasSetupCompareMode.current
71
+ ) {
55
72
  if (querySelection.expression === undefined) {
56
73
  return;
57
74
  }
@@ -96,13 +113,14 @@ export const useAutoQuerySelector = ({
96
113
  };
97
114
  }
98
115
 
116
+ hasSetupCompareMode.current = true;
99
117
  void navigateTo('/', {
100
118
  ...compareQuery,
101
119
  search_string: '',
102
120
  dashboard_items: ['flamegraph'],
103
121
  });
104
122
  }
105
- }, [comparing, querySelection, navigateTo, expressionA, dispatch, loading]);
123
+ }, [comparing, querySelection, navigateTo, expressionA, expressionB, dispatch, loading]);
106
124
 
107
125
  // Effect to load some initial data on load when is no selection
108
126
  useEffect(() => {
@@ -184,7 +184,9 @@ const ProfileTypeSelector = ({
184
184
  : [];
185
185
  }, [profileTypesData, error]);
186
186
 
187
- const profileLabels = profileNames.map(name => ({
187
+ const profileLabels = (
188
+ profileNames.length > 0 ? profileNames : selectedKey != null ? [selectedKey] : []
189
+ ).map(name => ({
188
190
  key: name,
189
191
  element: profileSelectElement(name, flexibleKnownProfilesDetection),
190
192
  }));
@@ -13,7 +13,7 @@
13
13
 
14
14
  import {ReactNode} from 'react';
15
15
 
16
- import {useParcaContext, useURLState} from '@parca/components';
16
+ import {useParcaContext, useURLState, useURLStateBatch} from '@parca/components';
17
17
 
18
18
  import {ProfileSource} from '../../../ProfileSource';
19
19
  import Dropdown, {DropdownElement, InnerAction} from './Dropdown';
@@ -31,6 +31,7 @@ const ViewSelector = ({profileSource}: Props): JSX.Element => {
31
31
  );
32
32
  const [, setSandwichFunctionName] = useURLState<string | undefined>('sandwich_function_name');
33
33
  const {enableSourcesView, enableSandwichView} = useParcaContext();
34
+ const batchUpdates = useURLStateBatch();
34
35
 
35
36
  const allItems: Array<{
36
37
  key: string;
@@ -127,11 +128,15 @@ const ViewSelector = ({profileSource}: Props): JSX.Element => {
127
128
  setDashboardItems([...dashboardItems, item.key]);
128
129
  } else {
129
130
  const newDashboardItems = dashboardItems.filter(v => v !== item.key);
130
- setDashboardItems(newDashboardItems);
131
131
 
132
- // Reset sandwich function name when removing sandwich panel
132
+ // Batch updates when removing sandwich panel to combine both URL changes
133
133
  if (item.key === 'sandwich') {
134
- setSandwichFunctionName(undefined);
134
+ batchUpdates(() => {
135
+ setDashboardItems(newDashboardItems);
136
+ setSandwichFunctionName(undefined);
137
+ });
138
+ } else {
139
+ setDashboardItems(newDashboardItems);
135
140
  }
136
141
  }
137
142
  },
@@ -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 {useURLState} from '@parca/components';
14
+ import {useURLState, useURLStateBatch} from '@parca/components';
15
15
 
16
16
  import {useProfileFilters} from '../components/ProfileFilters/useProfileFilters';
17
17
 
@@ -20,9 +20,11 @@ export const useResetStateOnProfileTypeChange = (): (() => void) => {
20
20
  const [curPath, setCurPath] = useURLState('cur_path');
21
21
  const {resetFilters} = useProfileFilters();
22
22
  const [sandwichFunctionName, setSandwichFunctionName] = useURLState('sandwich_function_name');
23
+ const batchUpdates = useURLStateBatch();
23
24
 
24
25
  return () => {
25
- setTimeout(() => {
26
+ // Batch all URL state resets into a single navigation
27
+ batchUpdates(() => {
26
28
  if (groupBy !== undefined) {
27
29
  setGroupBy(undefined);
28
30
  }
@@ -13,7 +13,13 @@
13
13
 
14
14
  import {useCallback, useMemo} from 'react';
15
15
 
16
- import {JSONParser, JSONSerializer, useURLState, useURLStateCustom} from '@parca/components';
16
+ import {
17
+ JSONParser,
18
+ JSONSerializer,
19
+ useURLState,
20
+ useURLStateBatch,
21
+ useURLStateCustom,
22
+ } from '@parca/components';
17
23
  import {USER_PREFERENCES, useUserPreference} from '@parca/hooks';
18
24
 
19
25
  import {
@@ -69,6 +75,7 @@ export const useVisualizationState = (): {
69
75
  'sandwich_function_name'
70
76
  );
71
77
  const resetFlameGraphState = useResetFlameGraphState();
78
+ const batchUpdates = useURLStateBatch();
72
79
 
73
80
  const levelsOfProfiling = useMemo(
74
81
  () => [
@@ -89,25 +96,31 @@ export const useVisualizationState = (): {
89
96
 
90
97
  const toggleGroupBy = useCallback(
91
98
  (key: string): void => {
92
- if (groupBy.includes(key)) {
93
- setGroupBy(groupBy.filter(v => v !== key)); // remove
94
- } else {
95
- const filteredGroupBy = groupBy.filter(item => !levelsOfProfiling.includes(item));
96
- setGroupBy([...filteredGroupBy, key]); // add
97
- }
99
+ // Batch updates to combine setGroupBy + resetFlameGraphState into single URL navigation
100
+ batchUpdates(() => {
101
+ if (groupBy.includes(key)) {
102
+ setGroupBy(groupBy.filter(v => v !== key)); // remove
103
+ } else {
104
+ const filteredGroupBy = groupBy.filter(item => !levelsOfProfiling.includes(item));
105
+ setGroupBy([...filteredGroupBy, key]); // add
106
+ }
98
107
 
99
- resetFlameGraphState();
108
+ resetFlameGraphState();
109
+ });
100
110
  },
101
- [groupBy, setGroupBy, levelsOfProfiling, resetFlameGraphState]
111
+ [groupBy, setGroupBy, levelsOfProfiling, resetFlameGraphState, batchUpdates]
102
112
  );
103
113
 
104
114
  const setGroupByLabels = useCallback(
105
115
  (labels: string[]): void => {
106
- setGroupBy(groupBy.filter(l => !l.startsWith(`${FIELD_LABELS}.`)).concat(labels));
116
+ // Batch updates to combine setGroupBy + resetFlameGraphState into single URL navigation
117
+ batchUpdates(() => {
118
+ setGroupBy(groupBy.filter(l => !l.startsWith(`${FIELD_LABELS}.`)).concat(labels));
107
119
 
108
- resetFlameGraphState();
120
+ resetFlameGraphState();
121
+ });
109
122
  },
110
- [groupBy, setGroupBy, resetFlameGraphState]
123
+ [groupBy, setGroupBy, resetFlameGraphState, batchUpdates]
111
124
  );
112
125
 
113
126
  const resetSandwichFunctionName = useCallback((): void => {
@@ -14,7 +14,7 @@
14
14
  import {Menu} from '@headlessui/react';
15
15
  import {Icon} from '@iconify/react';
16
16
 
17
- import {useParcaContext, useURLState} from '@parca/components';
17
+ import {useParcaContext, useURLState, useURLStateBatch} from '@parca/components';
18
18
 
19
19
  const MoreDropdown = ({functionName}: {functionName: string}): React.JSX.Element | null => {
20
20
  const [_, setSandwichFunctionName] = useURLState<string | undefined>('sandwich_function_name');
@@ -22,10 +22,14 @@ const MoreDropdown = ({functionName}: {functionName: string}): React.JSX.Element
22
22
  alwaysReturnArray: true,
23
23
  });
24
24
  const {enableSandwichView} = useParcaContext();
25
+ const batchUpdates = useURLStateBatch();
25
26
 
26
27
  const onSandwichViewSelect = (): void => {
27
- setSandwichFunctionName(functionName.trim());
28
- setDashboardItems([...dashboardItems, 'sandwich']);
28
+ // Batch updates to combine setSandwichFunctionName + setDashboardItems into single URL navigation
29
+ batchUpdates(() => {
30
+ setSandwichFunctionName(functionName.trim());
31
+ setDashboardItems([...dashboardItems, 'sandwich']);
32
+ });
29
33
  };
30
34
 
31
35
  const menuItems: Array<{label: string; action: () => void}> = [];
@@ -17,7 +17,7 @@ import {Item, Menu, Submenu} from 'react-contexify';
17
17
 
18
18
  import 'react-contexify/dist/ReactContexify.css';
19
19
 
20
- import {useParcaContext, useURLState} from '@parca/components';
20
+ import {useParcaContext, useURLState, useURLStateBatch} from '@parca/components';
21
21
  import {valueFormatter} from '@parca/utilities';
22
22
 
23
23
  import {type Row} from '.';
@@ -47,13 +47,17 @@ const TableContextMenu = ({
47
47
  alwaysReturnArray: true,
48
48
  });
49
49
  const {enableSandwichView, isDarkMode} = useParcaContext();
50
+ const batchUpdates = useURLStateBatch();
50
51
 
51
52
  const onSandwichViewSelect = (): void => {
52
53
  if (row?.name != null && row.name.length > 0) {
53
- setSandwichFunctionName(row.name.trim());
54
- if (!dashboardItems.includes('sandwich')) {
55
- setDashboardItems([...dashboardItems, 'sandwich']);
56
- }
54
+ // Batch updates to combine setSandwichFunctionName + setDashboardItems into single URL navigation
55
+ batchUpdates(() => {
56
+ setSandwichFunctionName(row.name.trim());
57
+ if (!dashboardItems.includes('sandwich')) {
58
+ setDashboardItems([...dashboardItems, 'sandwich']);
59
+ }
60
+ });
57
61
  }
58
62
  };
59
63
 
@@ -0,0 +1,131 @@
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 {useCallback} from 'react';
15
+
16
+ import {useURLState, useURLStateBatch} from '@parca/components';
17
+
18
+ /**
19
+ * Hook to manage compare mode state and operations
20
+ * Returns compare mode flags and a function to close compare mode
21
+ */
22
+ export const useCompareModeMeta = (): {
23
+ isCompareMode: boolean;
24
+ isCompareAbsolute: boolean;
25
+ closeCompareMode: (card: 'A' | 'B') => void;
26
+ } => {
27
+ const batchUpdates = useURLStateBatch();
28
+
29
+ // Side A URL state (only setters needed)
30
+ const [, setExpressionA] = useURLState<string>('expression_a');
31
+ const [, setFromA] = useURLState<string>('from_a');
32
+ const [, setToA] = useURLState<string>('to_a');
33
+ const [, setTimeSelectionA] = useURLState<string>('time_selection_a');
34
+ const [, setSumByA] = useURLState<string>('sum_by_a');
35
+ const [, setMergeFromA] = useURLState<string>('merge_from_a');
36
+ const [, setMergeToA] = useURLState<string>('merge_to_a');
37
+ const [, setSelectionA] = useURLState<string>('selection_a');
38
+
39
+ // Side B URL state
40
+ const [expressionB, setExpressionB] = useURLState<string>('expression_b');
41
+ const [fromB, setFromB] = useURLState<string>('from_b');
42
+ const [toB, setToB] = useURLState<string>('to_b');
43
+ const [timeSelectionB, setTimeSelectionB] = useURLState<string>('time_selection_b');
44
+ const [sumByB, setSumByB] = useURLState<string>('sum_by_b');
45
+ const [mergeFromB, setMergeFromB] = useURLState<string>('merge_from_b');
46
+ const [mergeToB, setMergeToB] = useURLState<string>('merge_to_b');
47
+ const [selectionB, setSelectionB] = useURLState<string>('selection_b');
48
+
49
+ // Compare mode flags (expose values for routing decisions)
50
+ const [compareA, setCompareA] = useURLState<string>('compare_a');
51
+ const [compareB, setCompareB] = useURLState<string>('compare_b');
52
+ const [compareAbsolute, setCompareAbsolute] = useURLState<string>('compare_absolute');
53
+
54
+ const closeCompareMode = useCallback(
55
+ (side: 'A' | 'B') => {
56
+ batchUpdates(() => {
57
+ // If closing side A, swap A and B params first (keep B's data as the single view)
58
+ if (side === 'A') {
59
+ // Copy B to A
60
+ setExpressionA(expressionB);
61
+ setFromA(fromB);
62
+ setToA(toB);
63
+ setTimeSelectionA(timeSelectionB);
64
+ setSumByA(sumByB);
65
+ setMergeFromA(mergeFromB);
66
+ setMergeToA(mergeToB);
67
+ setSelectionA(selectionB);
68
+ }
69
+
70
+ // Clear all B params
71
+ setExpressionB(undefined);
72
+ setFromB(undefined);
73
+ setToB(undefined);
74
+ setTimeSelectionB(undefined);
75
+ setSumByB(undefined);
76
+ setMergeFromB(undefined);
77
+ setMergeToB(undefined);
78
+ setSelectionB(undefined);
79
+
80
+ // Clear compare mode flags
81
+ setCompareA(undefined);
82
+ setCompareB(undefined);
83
+ setCompareAbsolute(undefined);
84
+ });
85
+ },
86
+ [
87
+ batchUpdates,
88
+ // Side A setters
89
+ setExpressionA,
90
+ setFromA,
91
+ setToA,
92
+ setTimeSelectionA,
93
+ setSumByA,
94
+ setMergeFromA,
95
+ setMergeToA,
96
+ setSelectionA,
97
+ // Side B values (for swapping)
98
+ expressionB,
99
+ fromB,
100
+ toB,
101
+ timeSelectionB,
102
+ sumByB,
103
+ mergeFromB,
104
+ mergeToB,
105
+ selectionB,
106
+ // Side B setters
107
+ setExpressionB,
108
+ setFromB,
109
+ setToB,
110
+ setTimeSelectionB,
111
+ setSumByB,
112
+ setMergeFromB,
113
+ setMergeToB,
114
+ setSelectionB,
115
+ // Compare flags
116
+ setCompareA,
117
+ setCompareB,
118
+ setCompareAbsolute,
119
+ ]
120
+ );
121
+
122
+ // Derive isCompareMode from flags
123
+ const isCompareMode = compareA === 'true' || compareB === 'true';
124
+ const isCompareAbsolute = compareAbsolute === 'true';
125
+
126
+ return {
127
+ isCompareMode,
128
+ isCompareAbsolute,
129
+ closeCompareMode,
130
+ };
131
+ };