@parca/profile 0.19.140 → 0.19.142

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 (253) hide show
  1. package/CHANGELOG.md +5 -1
  2. package/dist/GraphTooltipArrow/Content.js +224 -30
  3. package/dist/GraphTooltipArrow/DockedGraphTooltip/index.js +192 -33
  4. package/dist/GraphTooltipArrow/ExpandOnHoverValue.js +53 -3
  5. package/dist/GraphTooltipArrow/index.d.ts.map +1 -1
  6. package/dist/GraphTooltipArrow/index.js +86 -56
  7. package/dist/GraphTooltipArrow/useGraphTooltip/index.js +37 -37
  8. package/dist/GraphTooltipArrow/useGraphTooltipMetaInfo/index.d.ts.map +1 -1
  9. package/dist/GraphTooltipArrow/useGraphTooltipMetaInfo/index.js +104 -72
  10. package/dist/MatchersInput/SuggestionItem.js +91 -12
  11. package/dist/MatchersInput/SuggestionsList.d.ts +2 -1
  12. package/dist/MatchersInput/SuggestionsList.d.ts.map +1 -1
  13. package/dist/MatchersInput/SuggestionsList.js +371 -157
  14. package/dist/MatchersInput/SuggestionsList.test.d.ts +2 -0
  15. package/dist/MatchersInput/SuggestionsList.test.d.ts.map +1 -0
  16. package/dist/MatchersInput/index.js +308 -115
  17. package/dist/MetricsCircle/index.js +39 -3
  18. package/dist/MetricsGraph/MetricsContextMenu/index.js +119 -19
  19. package/dist/MetricsGraph/MetricsInfoPanel/index.js +81 -20
  20. package/dist/MetricsGraph/MetricsTooltip/index.d.ts.map +1 -1
  21. package/dist/MetricsGraph/MetricsTooltip/index.js +107 -74
  22. package/dist/MetricsGraph/index.js +552 -203
  23. package/dist/MetricsGraph/useMetricsGraphDimensions.js +46 -25
  24. package/dist/MetricsGraph/utils/colorMapping.js +24 -17
  25. package/dist/MetricsSeries/index.js +70 -7
  26. package/dist/PreSelectedMatchers/index.d.ts.map +1 -1
  27. package/dist/PreSelectedMatchers/index.js +249 -102
  28. package/dist/ProfileExplorer/ProfileExplorerCompare.d.ts.map +1 -1
  29. package/dist/ProfileExplorer/ProfileExplorerCompare.js +241 -45
  30. package/dist/ProfileExplorer/ProfileExplorerSingle.js +98 -11
  31. package/dist/ProfileExplorer/index.js +183 -32
  32. package/dist/ProfileFlameChart/SamplesStrips/SamplesGraph/index.js +333 -148
  33. package/dist/ProfileFlameChart/SamplesStrips/SamplesStrips.stories.js +69 -35
  34. package/dist/ProfileFlameChart/SamplesStrips/index.d.ts +2 -2
  35. package/dist/ProfileFlameChart/SamplesStrips/index.d.ts.map +1 -1
  36. package/dist/ProfileFlameChart/SamplesStrips/index.js +645 -134
  37. package/dist/ProfileFlameChart/SamplesStrips/labelSetUtils.js +114 -55
  38. package/dist/ProfileFlameChart/index.d.ts.map +1 -1
  39. package/dist/ProfileFlameChart/index.js +267 -129
  40. package/dist/ProfileFlameGraph/FlameGraphArrow/ContextMenu.d.ts.map +1 -1
  41. package/dist/ProfileFlameGraph/FlameGraphArrow/ContextMenu.js +288 -89
  42. package/dist/ProfileFlameGraph/FlameGraphArrow/ContextMenuWrapper.js +56 -20
  43. package/dist/ProfileFlameGraph/FlameGraphArrow/FlameGraphNodes.js +211 -140
  44. package/dist/ProfileFlameGraph/FlameGraphArrow/MemoizedTooltip.js +133 -38
  45. package/dist/ProfileFlameGraph/FlameGraphArrow/MiniMap.js +261 -216
  46. package/dist/ProfileFlameGraph/FlameGraphArrow/TextWithEllipsis.d.ts.map +1 -1
  47. package/dist/ProfileFlameGraph/FlameGraphArrow/TextWithEllipsis.js +72 -47
  48. package/dist/ProfileFlameGraph/FlameGraphArrow/TooltipContext.d.ts.map +1 -1
  49. package/dist/ProfileFlameGraph/FlameGraphArrow/TooltipContext.js +58 -28
  50. package/dist/ProfileFlameGraph/FlameGraphArrow/ZoomControls.d.ts.map +1 -1
  51. package/dist/ProfileFlameGraph/FlameGraphArrow/ZoomControls.js +59 -8
  52. package/dist/ProfileFlameGraph/FlameGraphArrow/index.js +396 -179
  53. package/dist/ProfileFlameGraph/FlameGraphArrow/useBatchedRendering.d.ts.map +1 -1
  54. package/dist/ProfileFlameGraph/FlameGraphArrow/useBatchedRendering.js +68 -50
  55. package/dist/ProfileFlameGraph/FlameGraphArrow/useMappingList.js +62 -38
  56. package/dist/ProfileFlameGraph/FlameGraphArrow/useNodeColor.js +14 -6
  57. package/dist/ProfileFlameGraph/FlameGraphArrow/useScrollViewport.js +124 -82
  58. package/dist/ProfileFlameGraph/FlameGraphArrow/useVisibleNodes.js +160 -98
  59. package/dist/ProfileFlameGraph/FlameGraphArrow/useZoom.js +232 -112
  60. package/dist/ProfileFlameGraph/FlameGraphArrow/utils.js +137 -114
  61. package/dist/ProfileFlameGraph/benchmarks/benchdata/populateData.js +85 -0
  62. package/dist/ProfileFlameGraph/index.d.ts.map +1 -1
  63. package/dist/ProfileFlameGraph/index.js +324 -150
  64. package/dist/ProfileMetricsGraph/hooks/useQueryRange.js +140 -32
  65. package/dist/ProfileMetricsGraph/index.d.ts.map +1 -1
  66. package/dist/ProfileMetricsGraph/index.js +519 -258
  67. package/dist/ProfileSelector/CompareButton.js +132 -12
  68. package/dist/ProfileSelector/MetricsGraphSection.d.ts.map +1 -1
  69. package/dist/ProfileSelector/MetricsGraphSection.js +236 -64
  70. package/dist/ProfileSelector/index.d.ts.map +1 -1
  71. package/dist/ProfileSelector/index.js +727 -141
  72. package/dist/ProfileSelector/useAutoQuerySelector.js +249 -130
  73. package/dist/ProfileSource.js +230 -163
  74. package/dist/ProfileTypeSelector/index.js +214 -125
  75. package/dist/ProfileView/components/ActionButtons/GroupByDropdown.js +50 -4
  76. package/dist/ProfileView/components/ActionButtons/SortByDropdown.d.ts.map +1 -1
  77. package/dist/ProfileView/components/ActionButtons/SortByDropdown.js +141 -35
  78. package/dist/ProfileView/components/ColorStackLegend.d.ts.map +1 -1
  79. package/dist/ProfileView/components/ColorStackLegend.js +185 -55
  80. package/dist/ProfileView/components/DashboardItems/index.js +87 -28
  81. package/dist/ProfileView/components/DashboardLayout/index.js +108 -16
  82. package/dist/ProfileView/components/DiffLegend.js +172 -29
  83. package/dist/ProfileView/components/GroupByLabelsDropdown/index.js +199 -55
  84. package/dist/ProfileView/components/InvertCallStack/index.d.ts.map +1 -1
  85. package/dist/ProfileView/components/InvertCallStack/index.js +100 -12
  86. package/dist/ProfileView/components/ProfileFilters/filterPresets.js +260 -315
  87. package/dist/ProfileView/components/ProfileFilters/index.js +518 -215
  88. package/dist/ProfileView/components/ProfileFilters/useProfileFilters.js +370 -306
  89. package/dist/ProfileView/components/ProfileFilters/useProfileFiltersUrlState.d.ts +2 -1
  90. package/dist/ProfileView/components/ProfileFilters/useProfileFiltersUrlState.d.ts.map +1 -1
  91. package/dist/ProfileView/components/ProfileFilters/useProfileFiltersUrlState.js +188 -118
  92. package/dist/ProfileView/components/ProfileHeader/index.js +105 -11
  93. package/dist/ProfileView/components/ShareButton/ResultBox.js +119 -16
  94. package/dist/ProfileView/components/ShareButton/index.js +352 -62
  95. package/dist/ProfileView/components/Toolbars/MultiLevelDropdown.d.ts.map +1 -1
  96. package/dist/ProfileView/components/Toolbars/MultiLevelDropdown.js +678 -194
  97. package/dist/ProfileView/components/Toolbars/SwitchMenuItem.js +94 -7
  98. package/dist/ProfileView/components/Toolbars/TableColumnsDropdown.d.ts.map +1 -1
  99. package/dist/ProfileView/components/Toolbars/TableColumnsDropdown.js +199 -157
  100. package/dist/ProfileView/components/Toolbars/index.d.ts +2 -2
  101. package/dist/ProfileView/components/Toolbars/index.d.ts.map +1 -1
  102. package/dist/ProfileView/components/Toolbars/index.js +441 -21
  103. package/dist/ProfileView/components/ViewSelector/Dropdown.js +233 -22
  104. package/dist/ProfileView/components/ViewSelector/index.d.ts.map +1 -1
  105. package/dist/ProfileView/components/ViewSelector/index.js +212 -86
  106. package/dist/ProfileView/components/VisualizationContainer/index.d.ts.map +1 -1
  107. package/dist/ProfileView/components/VisualizationContainer/index.js +52 -7
  108. package/dist/ProfileView/components/VisualizationPanel.js +185 -8
  109. package/dist/ProfileView/context/DashboardContext.d.ts.map +1 -1
  110. package/dist/ProfileView/context/DashboardContext.js +85 -29
  111. package/dist/ProfileView/context/ProfileViewContext.js +56 -15
  112. package/dist/ProfileView/hooks/useAutoSelectDimension.js +71 -41
  113. package/dist/ProfileView/hooks/useProfileMetadata.js +50 -18
  114. package/dist/ProfileView/hooks/useResetFlameGraphState.d.ts.map +1 -1
  115. package/dist/ProfileView/hooks/useResetFlameGraphState.js +32 -12
  116. package/dist/ProfileView/hooks/useResetStateOnProfileTypeChange.d.ts.map +1 -1
  117. package/dist/ProfileView/hooks/useResetStateOnProfileTypeChange.js +71 -27
  118. package/dist/ProfileView/hooks/useResetStateOnSeriesChange.d.ts.map +1 -1
  119. package/dist/ProfileView/hooks/useResetStateOnSeriesChange.js +40 -19
  120. package/dist/ProfileView/hooks/useVisualizationState.d.ts +3 -3
  121. package/dist/ProfileView/hooks/useVisualizationState.d.ts.map +1 -1
  122. package/dist/ProfileView/hooks/useVisualizationState.js +258 -67
  123. package/dist/ProfileView/index.js +383 -45
  124. package/dist/ProfileView/types/visualization.js +1 -13
  125. package/dist/ProfileView/utils/colorUtils.js +8 -7
  126. package/dist/ProfileViewWithData.d.ts.map +1 -1
  127. package/dist/ProfileViewWithData.js +332 -228
  128. package/dist/QueryControls/index.js +418 -47
  129. package/dist/Sandwich/components/CalleesSection.js +54 -4
  130. package/dist/Sandwich/components/CallersSection.js +97 -27
  131. package/dist/Sandwich/components/TableSection.js +77 -4
  132. package/dist/Sandwich/index.d.ts.map +1 -1
  133. package/dist/Sandwich/index.js +126 -14
  134. package/dist/Sandwich/utils/processRowData.js +48 -39
  135. package/dist/SelectWithRefresh/index.js +102 -28
  136. package/dist/SimpleMatchers/Select.js +520 -187
  137. package/dist/SimpleMatchers/index.js +590 -288
  138. package/dist/SourceView/Highlighter.js +230 -70
  139. package/dist/SourceView/LineNo.js +72 -17
  140. package/dist/SourceView/index.d.ts.map +1 -1
  141. package/dist/SourceView/index.js +178 -104
  142. package/dist/SourceView/lang-detector/ext-to-lang.json +798 -798
  143. package/dist/SourceView/lang-detector/index.js +28 -14
  144. package/dist/SourceView/useSelectedLineRange.d.ts.map +1 -1
  145. package/dist/SourceView/useSelectedLineRange.js +99 -23
  146. package/dist/Table/ColorCell.js +42 -1
  147. package/dist/Table/ColumnsVisibility.js +114 -6
  148. package/dist/Table/MoreDropdown.d.ts.map +1 -1
  149. package/dist/Table/MoreDropdown.js +122 -25
  150. package/dist/Table/TableContextMenu.d.ts.map +1 -1
  151. package/dist/Table/TableContextMenu.js +151 -137
  152. package/dist/Table/TableContextMenuWrapper.js +59 -14
  153. package/dist/Table/hooks/useColorManagement.js +58 -16
  154. package/dist/Table/hooks/useTableConfiguration.d.ts.map +1 -1
  155. package/dist/Table/hooks/useTableConfiguration.js +333 -169
  156. package/dist/Table/index.d.ts.map +1 -1
  157. package/dist/Table/index.js +222 -128
  158. package/dist/Table/utils/functions.js +169 -144
  159. package/dist/Table/utils/topAndBottomExpandedRowModel.js +69 -52
  160. package/dist/TimelineGuide/index.js +209 -16
  161. package/dist/TopTable/benchmarks/benchdata/populateData.js +91 -0
  162. package/dist/TopTable/index.d.ts.map +1 -1
  163. package/dist/TopTable/index.js +342 -123
  164. package/dist/contexts/LabelsQueryProvider.js +94 -32
  165. package/dist/contexts/UnifiedLabelsContext.js +114 -49
  166. package/dist/contexts/utils.js +37 -15
  167. package/dist/hooks/useCompareModeMeta.d.ts.map +1 -1
  168. package/dist/hooks/useCompareModeMeta.js +158 -64
  169. package/dist/hooks/useLabels.js +295 -52
  170. package/dist/hooks/useQueryState.d.ts +3 -3
  171. package/dist/hooks/useQueryState.d.ts.map +1 -1
  172. package/dist/hooks/useQueryState.js +373 -332
  173. package/dist/index.d.ts +2 -3
  174. package/dist/index.d.ts.map +1 -1
  175. package/dist/index.js +22 -8
  176. package/dist/testdata/fg-diff.json +3750 -0
  177. package/dist/testdata/fg-simple.json +1879 -0
  178. package/dist/testdata/link_data.json +56 -0
  179. package/dist/testdata/tabular.json +30 -0
  180. package/dist/testdata/test_flamegraph.json +26846 -0
  181. package/dist/testdata/test_graph.json +53 -0
  182. package/dist/useDelayedLoader.js +32 -18
  183. package/dist/useGrpcQuery/index.js +71 -11
  184. package/dist/useHasProfileData.js +90 -12
  185. package/dist/useQuery.js +205 -64
  186. package/dist/useSumBy.d.ts +1 -1
  187. package/dist/useSumBy.d.ts.map +1 -1
  188. package/dist/useSumBy.js +294 -138
  189. package/dist/utils.js +62 -30
  190. package/package.json +9 -10
  191. package/src/GraphTooltipArrow/index.tsx +3 -0
  192. package/src/GraphTooltipArrow/useGraphTooltipMetaInfo/index.ts +13 -11
  193. package/src/MatchersInput/SuggestionsList.test.tsx +70 -0
  194. package/src/MatchersInput/SuggestionsList.tsx +11 -10
  195. package/src/MatchersInput/index.tsx +1 -1
  196. package/src/MetricsGraph/MetricsTooltip/index.tsx +22 -34
  197. package/src/PreSelectedMatchers/index.tsx +3 -0
  198. package/src/ProfileExplorer/ProfileExplorerCompare.tsx +9 -4
  199. package/src/ProfileFlameChart/SamplesStrips/index.tsx +2 -2
  200. package/src/ProfileFlameChart/index.tsx +28 -21
  201. package/src/ProfileFlameGraph/FlameGraphArrow/ContextMenu.tsx +9 -10
  202. package/src/ProfileFlameGraph/FlameGraphArrow/TextWithEllipsis.tsx +6 -5
  203. package/src/ProfileFlameGraph/FlameGraphArrow/TooltipContext.tsx +3 -0
  204. package/src/ProfileFlameGraph/FlameGraphArrow/ZoomControls.tsx +3 -0
  205. package/src/ProfileFlameGraph/FlameGraphArrow/useBatchedRendering.ts +3 -0
  206. package/src/ProfileFlameGraph/index.tsx +9 -6
  207. package/src/ProfileMetricsGraph/index.tsx +8 -6
  208. package/src/ProfileSelector/MetricsGraphSection.tsx +10 -5
  209. package/src/ProfileSelector/index.tsx +61 -39
  210. package/src/ProfileView/components/ActionButtons/SortByDropdown.tsx +6 -10
  211. package/src/ProfileView/components/ColorStackLegend.tsx +4 -2
  212. package/src/ProfileView/components/InvertCallStack/index.tsx +4 -5
  213. package/src/ProfileView/components/ProfileFilters/useProfileFiltersUrlState.test.tsx +192 -94
  214. package/src/ProfileView/components/ProfileFilters/useProfileFiltersUrlState.ts +21 -21
  215. package/src/ProfileView/components/Toolbars/MultiLevelDropdown.tsx +28 -24
  216. package/src/ProfileView/components/Toolbars/TableColumnsDropdown.tsx +5 -4
  217. package/src/ProfileView/components/Toolbars/index.tsx +3 -3
  218. package/src/ProfileView/components/ViewSelector/index.tsx +16 -9
  219. package/src/ProfileView/components/VisualizationContainer/index.tsx +3 -0
  220. package/src/ProfileView/context/DashboardContext.tsx +6 -6
  221. package/src/ProfileView/hooks/useResetFlameGraphState.ts +4 -6
  222. package/src/ProfileView/hooks/useResetStateOnProfileTypeChange.ts +26 -24
  223. package/src/ProfileView/hooks/useResetStateOnSeriesChange.ts +8 -16
  224. package/src/ProfileView/hooks/useVisualizationState.ts +69 -61
  225. package/src/ProfileViewWithData.tsx +35 -29
  226. package/src/Sandwich/index.tsx +3 -4
  227. package/src/SourceView/index.tsx +2 -4
  228. package/src/SourceView/useSelectedLineRange.ts +19 -34
  229. package/src/Table/MoreDropdown.tsx +11 -9
  230. package/src/Table/TableContextMenu.tsx +13 -10
  231. package/src/Table/hooks/useTableConfiguration.tsx +11 -16
  232. package/src/Table/index.tsx +21 -12
  233. package/src/TopTable/index.tsx +4 -3
  234. package/src/hooks/useCompareModeMeta.ts +91 -61
  235. package/src/hooks/useQueryState.test.tsx +345 -275
  236. package/src/hooks/useQueryState.ts +118 -136
  237. package/src/index.tsx +15 -16
  238. package/src/useDelayedLoader.ts +10 -10
  239. package/src/useSumBy.ts +15 -21
  240. package/dist/ProfileView/components/ProfileFilters/useProfileFiltersUrlState.test.js +0 -455
  241. package/dist/hooks/urlParsers.d.ts +0 -18
  242. package/dist/hooks/urlParsers.d.ts.map +0 -1
  243. package/dist/hooks/urlParsers.js +0 -32
  244. package/dist/hooks/useColorBy.d.ts +0 -5
  245. package/dist/hooks/useColorBy.d.ts.map +0 -1
  246. package/dist/hooks/useColorBy.js +0 -26
  247. package/dist/hooks/useDashboardItems.d.ts +0 -5
  248. package/dist/hooks/useDashboardItems.d.ts.map +0 -1
  249. package/dist/hooks/useDashboardItems.js +0 -27
  250. package/dist/hooks/useQueryState.test.js +0 -868
  251. package/src/hooks/urlParsers.ts +0 -38
  252. package/src/hooks/useColorBy.ts +0 -42
  253. package/src/hooks/useDashboardItems.ts +0 -46
@@ -17,13 +17,59 @@ import {ReactNode, act} from 'react';
17
17
  import {QueryClient, QueryClientProvider} from '@tanstack/react-query';
18
18
  // eslint-disable-next-line import/named
19
19
  import {renderHook, waitFor} from '@testing-library/react';
20
- // eslint-disable-next-line import/no-unresolved
21
- import {NuqsTestingAdapter} from 'nuqs/adapters/testing';
22
20
  import {beforeEach, describe, expect, it, vi} from 'vitest';
23
21
 
22
+ import {URLStateProvider} from '@parca/components';
23
+
24
24
  import {useQueryState} from './useQueryState';
25
25
 
26
- const mockNavigateTo = vi.fn();
26
+ // Mock window.location
27
+ const mockLocation = {
28
+ pathname: '/test',
29
+ search: '',
30
+ };
31
+
32
+ // Mock the navigate function that actually updates the mock location
33
+ const mockNavigateTo = vi.fn((path: string, params: Record<string, string | string[]>) => {
34
+ // Convert params object to query string
35
+ const searchParams = new URLSearchParams();
36
+ Object.entries(params).forEach(([key, value]) => {
37
+ if (value !== undefined && value !== null) {
38
+ if (Array.isArray(value)) {
39
+ // For arrays, join with commas
40
+ searchParams.set(key, value.join(','));
41
+ } else {
42
+ searchParams.set(key, String(value));
43
+ }
44
+ }
45
+ });
46
+ mockLocation.search = `?${searchParams.toString()}`;
47
+ });
48
+
49
+ // Mock the getQueryParamsFromURL function
50
+ vi.mock('@parca/components/src/hooks/URLState/utils', async () => {
51
+ const actual = await vi.importActual('@parca/components/src/hooks/URLState/utils');
52
+ return {
53
+ ...actual,
54
+ getQueryParamsFromURL: () => {
55
+ if (mockLocation.search === '') return {};
56
+ const params = new URLSearchParams(mockLocation.search);
57
+ const result: Record<string, string | string[]> = {};
58
+ for (const [key, value] of params.entries()) {
59
+ const decodedValue = decodeURIComponent(value);
60
+ const existing = result[key];
61
+ if (existing !== undefined) {
62
+ result[key] = Array.isArray(existing)
63
+ ? [...existing, decodedValue]
64
+ : [existing, decodedValue];
65
+ } else {
66
+ result[key] = decodedValue;
67
+ }
68
+ }
69
+ return result;
70
+ },
71
+ };
72
+ });
27
73
 
28
74
  // Mock useSumBy with stateful behavior using React's useState
29
75
  vi.mock('../useSumBy', async () => {
@@ -92,10 +138,9 @@ const setProfileTypesData = (data: typeof mockProfileTypesData): void => {
92
138
  mockProfileTypesData = data;
93
139
  };
94
140
 
95
- // Helper to create wrapper with NuqsTestingAdapter
141
+ // Helper to create wrapper with URLStateProvider
96
142
  const createWrapper = (
97
- _paramPreferences = {},
98
- searchParams: string | Record<string, string> = {}
143
+ paramPreferences = {}
99
144
  ): (({children}: {children: ReactNode}) => JSX.Element) => {
100
145
  const queryClient = new QueryClient({
101
146
  defaultOptions: {
@@ -105,17 +150,24 @@ const createWrapper = (
105
150
  },
106
151
  });
107
152
  const Wrapper = ({children}: {children: ReactNode}): JSX.Element => (
108
- <NuqsTestingAdapter searchParams={searchParams} hasMemory={true}>
109
- <QueryClientProvider client={queryClient}>{children}</QueryClientProvider>
110
- </NuqsTestingAdapter>
153
+ <QueryClientProvider client={queryClient}>
154
+ <URLStateProvider navigateTo={mockNavigateTo} paramPreferences={paramPreferences}>
155
+ {children}
156
+ </URLStateProvider>
157
+ </QueryClientProvider>
111
158
  );
112
- Wrapper.displayName = 'NuqsTestingWrapper';
159
+ Wrapper.displayName = 'URLStateProviderWrapper';
113
160
  return Wrapper;
114
161
  };
115
162
 
116
163
  describe('useQueryState', () => {
117
164
  beforeEach(() => {
118
165
  mockNavigateTo.mockClear();
166
+ Object.defineProperty(window, 'location', {
167
+ value: mockLocation,
168
+ writable: true,
169
+ });
170
+ mockLocation.search = '';
119
171
  // Reset profile types mock state
120
172
  setProfileTypesLoading(false);
121
173
  setProfileTypesData(undefined);
@@ -143,12 +195,10 @@ describe('useQueryState', () => {
143
195
  });
144
196
 
145
197
  it('should handle suffix for comparison mode', () => {
146
- const {result} = renderHook(() => useQueryState({suffix: '_a'}), {
147
- wrapper: createWrapper(
148
- {},
149
- '?expression_a=process_cpu:cpu:nanoseconds:cpu:nanoseconds:delta{}&from_a=1000&to_a=2000'
150
- ),
151
- });
198
+ mockLocation.search =
199
+ '?expression_a=process_cpu:cpu:nanoseconds:cpu:nanoseconds:delta{}&from_a=1000&to_a=2000';
200
+
201
+ const {result} = renderHook(() => useQueryState({suffix: '_a'}), {wrapper: createWrapper()});
152
202
 
153
203
  const {querySelection} = result.current;
154
204
  expect(querySelection.expression).toBe('process_cpu:cpu:nanoseconds:cpu:nanoseconds:delta{}');
@@ -186,11 +236,14 @@ describe('useQueryState', () => {
186
236
  });
187
237
 
188
238
  await waitFor(() => {
189
- expect(result.current.querySelection.expression).toBe(
190
- 'memory:alloc_objects:count:space:bytes:delta{}'
191
- );
192
- expect(result.current.querySelection.mergeFrom).toBe('1000000000');
193
- expect(result.current.querySelection.mergeTo).toBe('2000000000');
239
+ expect(mockNavigateTo).toHaveBeenCalled();
240
+ const [, params] = mockNavigateTo.mock.calls[mockNavigateTo.mock.calls.length - 1];
241
+ expect(params.expression).toBe('memory:alloc_objects:count:space:bytes:delta{}');
242
+ // Should set merge parameters for delta profile
243
+ expect(params).toHaveProperty('merge_from');
244
+ expect(params).toHaveProperty('merge_to');
245
+ expect(params.merge_from).toBe('1000000000');
246
+ expect(params.merge_to).toBe('2000000000');
194
247
  });
195
248
  });
196
249
 
@@ -211,9 +264,11 @@ describe('useQueryState', () => {
211
264
  });
212
265
 
213
266
  await waitFor(() => {
214
- expect(String(result.current.querySelection.from)).toBe('3000');
215
- expect(String(result.current.querySelection.to)).toBe('4000');
216
- expect(result.current.querySelection.timeSelection).toBe('relative:minute|5');
267
+ expect(mockNavigateTo).toHaveBeenCalled();
268
+ const [, params] = mockNavigateTo.mock.calls[mockNavigateTo.mock.calls.length - 1];
269
+ expect(params.from).toBe('3000');
270
+ expect(params.to).toBe('4000');
271
+ expect(params.time_selection).toBe('relative:minute|5');
217
272
  });
218
273
  });
219
274
 
@@ -234,8 +289,9 @@ describe('useQueryState', () => {
234
289
  });
235
290
 
236
291
  await waitFor(() => {
237
- // sumBy is managed by the mocked useSumBy hook; verify it was set in draft
238
- expect(result.current.draftSelection.sumBy).toEqual(['namespace', 'container']);
292
+ expect(mockNavigateTo).toHaveBeenCalled();
293
+ const [, params] = mockNavigateTo.mock.calls[mockNavigateTo.mock.calls.length - 1];
294
+ expect(params.sum_by).toBe('namespace,container');
239
295
  });
240
296
  });
241
297
 
@@ -257,8 +313,10 @@ describe('useQueryState', () => {
257
313
  });
258
314
 
259
315
  await waitFor(() => {
260
- expect(result.current.querySelection.mergeFrom).toBe('5000000000');
261
- expect(result.current.querySelection.mergeTo).toBe('6000000000');
316
+ expect(mockNavigateTo).toHaveBeenCalled();
317
+ const [, params] = mockNavigateTo.mock.calls[mockNavigateTo.mock.calls.length - 1];
318
+ expect(params.merge_from).toBe('5000000000');
319
+ expect(params.merge_to).toBe('6000000000');
262
320
  });
263
321
  });
264
322
  });
@@ -287,25 +345,22 @@ describe('useQueryState', () => {
287
345
  });
288
346
 
289
347
  await waitFor(() => {
290
- // Verify all state values are correct after the batch
291
- expect(result.current.querySelection.expression).toBe(
292
- 'memory:alloc_space:bytes:space:bytes:delta{}'
293
- );
294
- expect(String(result.current.querySelection.from)).toBe('7000');
295
- expect(String(result.current.querySelection.to)).toBe('8000');
296
- expect(result.current.querySelection.timeSelection).toBe('relative:minute|30');
297
- // sumBy is managed by the mocked useSumBy hook; verify it was set in draft
298
- expect(result.current.draftSelection.sumBy).toEqual(['pod', 'node']);
348
+ // Should only navigate once for all updates
349
+ expect(mockNavigateTo).toHaveBeenCalledTimes(1);
350
+ const [, params] = mockNavigateTo.mock.calls[0];
351
+ expect(params.expression).toBe('memory:alloc_space:bytes:space:bytes:delta{}');
352
+ expect(params.from).toBe('7000');
353
+ expect(params.to).toBe('8000');
354
+ expect(params.time_selection).toBe('relative:minute|30');
355
+ expect(params.sum_by).toBe('pod,node');
299
356
  });
300
357
  });
301
358
 
302
359
  it('should handle partial updates', async () => {
303
- const {result} = renderHook(() => useQueryState(), {
304
- wrapper: createWrapper(
305
- {},
306
- '?expression=process_cpu:cpu:nanoseconds:cpu:nanoseconds:delta{}&from=1000&to=2000&time_selection=relative:hour|1'
307
- ),
308
- });
360
+ mockLocation.search =
361
+ '?expression=process_cpu:cpu:nanoseconds:cpu:nanoseconds:delta{}&from=1000&to=2000&time_selection=relative:hour|1';
362
+
363
+ const {result} = renderHook(() => useQueryState(), {wrapper: createWrapper()});
309
364
 
310
365
  act(() => {
311
366
  // Only update expression, other values should remain
@@ -324,12 +379,12 @@ describe('useQueryState', () => {
324
379
  });
325
380
 
326
381
  await waitFor(() => {
327
- expect(result.current.querySelection.expression).toBe(
328
- 'memory:inuse_space:bytes:space:bytes{}'
329
- );
330
- expect(String(result.current.querySelection.from)).toBe('1000');
331
- expect(String(result.current.querySelection.to)).toBe('2000');
332
- expect(result.current.querySelection.timeSelection).toBe('relative:hour|1');
382
+ expect(mockNavigateTo).toHaveBeenCalled();
383
+ const [, params] = mockNavigateTo.mock.calls[mockNavigateTo.mock.calls.length - 1];
384
+ expect(params.expression).toBe('memory:inuse_space:bytes:space:bytes{}');
385
+ expect(params.from).toBe('1000');
386
+ expect(params.to).toBe('2000');
387
+ expect(params.time_selection).toBe('relative:hour|1');
333
388
  });
334
389
  });
335
390
 
@@ -350,23 +405,21 @@ describe('useQueryState', () => {
350
405
  });
351
406
 
352
407
  await waitFor(() => {
353
- expect(result.current.querySelection.expression).toBe(
354
- 'memory:alloc_space:bytes:space:bytes:delta{}'
355
- );
356
- expect(result.current.querySelection.mergeFrom).toBe('9000000000');
357
- expect(result.current.querySelection.mergeTo).toBe('10000000000');
408
+ expect(mockNavigateTo).toHaveBeenCalled();
409
+ const [, params] = mockNavigateTo.mock.calls[mockNavigateTo.mock.calls.length - 1];
410
+ expect(params.expression).toBe('memory:alloc_space:bytes:space:bytes:delta{}');
411
+ expect(params.merge_from).toBe('9000000000');
412
+ expect(params.merge_to).toBe('10000000000');
358
413
  });
359
414
  });
360
415
  });
361
416
 
362
417
  describe('Helper functions', () => {
363
418
  it('should set profile name correctly', async () => {
364
- const {result} = renderHook(() => useQueryState(), {
365
- wrapper: createWrapper(
366
- {},
367
- '?expression=process_cpu:cpu:nanoseconds:cpu:nanoseconds:delta{job="parca"}'
368
- ),
369
- });
419
+ mockLocation.search =
420
+ '?expression=process_cpu:cpu:nanoseconds:cpu:nanoseconds:delta{job="parca"}';
421
+
422
+ const {result} = renderHook(() => useQueryState(), {wrapper: createWrapper()});
370
423
 
371
424
  act(() => {
372
425
  result.current.setDraftProfileName('memory:inuse_space:bytes:space:bytes');
@@ -382,19 +435,16 @@ describe('useQueryState', () => {
382
435
  });
383
436
 
384
437
  await waitFor(() => {
385
- expect(result.current.querySelection.expression).toBe(
386
- 'memory:inuse_space:bytes:space:bytes{job="parca"}'
387
- );
438
+ expect(mockNavigateTo).toHaveBeenCalled();
439
+ const [, params] = mockNavigateTo.mock.calls[mockNavigateTo.mock.calls.length - 1];
440
+ expect(params.expression).toBe('memory:inuse_space:bytes:space:bytes{job="parca"}');
388
441
  });
389
442
  });
390
443
 
391
444
  it('should set matchers correctly using draft', async () => {
392
- const {result} = renderHook(() => useQueryState(), {
393
- wrapper: createWrapper(
394
- {},
395
- '?expression=process_cpu:cpu:nanoseconds:cpu:nanoseconds:delta{}'
396
- ),
397
- });
445
+ mockLocation.search = '?expression=process_cpu:cpu:nanoseconds:cpu:nanoseconds:delta{}';
446
+
447
+ const {result} = renderHook(() => useQueryState(), {wrapper: createWrapper()});
398
448
 
399
449
  act(() => {
400
450
  result.current.setDraftMatchers('namespace="default",pod="my-pod"');
@@ -404,6 +454,7 @@ describe('useQueryState', () => {
404
454
  expect(result.current.draftSelection.expression).toBe(
405
455
  'process_cpu:cpu:nanoseconds:cpu:nanoseconds:delta{namespace="default",pod="my-pod"}'
406
456
  );
457
+ expect(mockNavigateTo).not.toHaveBeenCalled();
407
458
 
408
459
  // Commit the draft
409
460
  act(() => {
@@ -411,7 +462,9 @@ describe('useQueryState', () => {
411
462
  });
412
463
 
413
464
  await waitFor(() => {
414
- expect(result.current.querySelection.expression).toBe(
465
+ expect(mockNavigateTo).toHaveBeenCalled();
466
+ const [, params] = mockNavigateTo.mock.calls[mockNavigateTo.mock.calls.length - 1];
467
+ expect(params.expression).toBe(
415
468
  'process_cpu:cpu:nanoseconds:cpu:nanoseconds:delta{namespace="default",pod="my-pod"}'
416
469
  );
417
470
  });
@@ -435,13 +488,12 @@ describe('useQueryState', () => {
435
488
  });
436
489
 
437
490
  await waitFor(() => {
438
- expect(result.current.querySelection.expression).toBe(
439
- 'process_cpu:cpu:nanoseconds:cpu:nanoseconds:delta{}'
440
- );
441
- expect(String(result.current.querySelection.from)).toBe('1111');
442
- expect(String(result.current.querySelection.to)).toBe('2222');
443
- // sumBy is managed by the mocked useSumBy hook; verify it was set in draft
444
- expect(result.current.draftSelection.sumBy).toEqual(['label_a']);
491
+ expect(mockNavigateTo).toHaveBeenCalled();
492
+ const [, params] = mockNavigateTo.mock.calls[mockNavigateTo.mock.calls.length - 1];
493
+ expect(params.expression_a).toBe('process_cpu:cpu:nanoseconds:cpu:nanoseconds:delta{}');
494
+ expect(params.from_a).toBe('1111');
495
+ expect(params.to_a).toBe('2222');
496
+ expect(params.sum_by_a).toBe('label_a');
445
497
  });
446
498
  });
447
499
 
@@ -461,13 +513,12 @@ describe('useQueryState', () => {
461
513
  });
462
514
 
463
515
  await waitFor(() => {
464
- expect(result.current.querySelection.expression).toBe(
465
- 'memory:alloc_space:bytes:space:bytes:delta{}'
466
- );
467
- expect(String(result.current.querySelection.from)).toBe('3333');
468
- expect(String(result.current.querySelection.to)).toBe('4444');
469
- // sumBy is managed by the mocked useSumBy hook; verify it was set in draft
470
- expect(result.current.draftSelection.sumBy).toEqual(['label_b']);
516
+ expect(mockNavigateTo).toHaveBeenCalled();
517
+ const [, params] = mockNavigateTo.mock.calls[mockNavigateTo.mock.calls.length - 1];
518
+ expect(params.expression_b).toBe('memory:alloc_space:bytes:space:bytes:delta{}');
519
+ expect(params.from_b).toBe('3333');
520
+ expect(params.to_b).toBe('4444');
521
+ expect(params.sum_by_b).toBe('label_b');
471
522
  });
472
523
  });
473
524
  });
@@ -483,30 +534,30 @@ describe('useQueryState', () => {
483
534
  result.current.setDraftSumBy(['namespace', 'pod']);
484
535
  });
485
536
 
537
+ // URL should not be updated yet
538
+ expect(mockNavigateTo).not.toHaveBeenCalled();
539
+
486
540
  // Commit all changes at once
487
541
  act(() => {
488
542
  result.current.commitDraft();
489
543
  });
490
544
 
491
- // Verify all state values are correct
545
+ // Now URL should be updated exactly once with all changes
492
546
  await waitFor(() => {
493
- expect(result.current.querySelection.expression).toBe(
494
- 'memory:alloc_space:bytes:space:bytes:delta{}'
495
- );
496
- expect(String(result.current.querySelection.from)).toBe('5000');
497
- expect(String(result.current.querySelection.to)).toBe('6000');
498
- // sumBy is managed by the mocked useSumBy hook; verify it was set in draft
499
- expect(result.current.draftSelection.sumBy).toEqual(['namespace', 'pod']);
547
+ expect(mockNavigateTo).toHaveBeenCalledTimes(1);
548
+ const [, params] = mockNavigateTo.mock.calls[0];
549
+ expect(params.expression).toBe('memory:alloc_space:bytes:space:bytes:delta{}');
550
+ expect(params.from).toBe('5000');
551
+ expect(params.to).toBe('6000');
552
+ expect(params.sum_by).toBe('namespace,pod');
500
553
  });
501
554
  });
502
555
 
503
556
  it('should handle draft profile name changes', () => {
504
- const {result} = renderHook(() => useQueryState(), {
505
- wrapper: createWrapper(
506
- {},
507
- '?expression=process_cpu:cpu:nanoseconds:cpu:nanoseconds:delta{job="test"}'
508
- ),
509
- });
557
+ mockLocation.search =
558
+ '?expression=process_cpu:cpu:nanoseconds:cpu:nanoseconds:delta{job="test"}';
559
+
560
+ const {result} = renderHook(() => useQueryState(), {wrapper: createWrapper()});
510
561
 
511
562
  // Change profile name in draft
512
563
  act(() => {
@@ -517,6 +568,9 @@ describe('useQueryState', () => {
517
568
  expect(result.current.draftSelection.expression).toBe(
518
569
  'memory:inuse_space:bytes:space:bytes{job="test"}'
519
570
  );
571
+
572
+ // URL should not be updated yet
573
+ expect(mockNavigateTo).not.toHaveBeenCalled();
520
574
  });
521
575
  });
522
576
 
@@ -562,12 +616,10 @@ describe('useQueryState', () => {
562
616
  });
563
617
 
564
618
  it('should clear merge params for non-delta profiles', async () => {
565
- const {result} = renderHook(() => useQueryState(), {
566
- wrapper: createWrapper(
567
- {},
568
- '?expression=memory:alloc_objects:count:space:bytes:delta{}&merge_from=1000000000&merge_to=2000000000'
569
- ),
570
- });
619
+ mockLocation.search =
620
+ '?expression=memory:alloc_objects:count:space:bytes:delta{}&merge_from=1000000000&merge_to=2000000000';
621
+
622
+ const {result} = renderHook(() => useQueryState(), {wrapper: createWrapper()});
571
623
 
572
624
  // Switch to non-delta profile (without :delta suffix) using draft
573
625
  act(() => {
@@ -580,22 +632,19 @@ describe('useQueryState', () => {
580
632
  });
581
633
 
582
634
  await waitFor(() => {
583
- expect(result.current.querySelection.expression).toBe(
584
- 'memory:inuse_space:bytes:space:bytes{}'
585
- );
586
- // Merge params should not be set for non-delta profiles
587
- expect(result.current.querySelection.mergeFrom).toBeUndefined();
588
- expect(result.current.querySelection.mergeTo).toBeUndefined();
635
+ expect(mockNavigateTo).toHaveBeenCalled();
636
+ const [, params] = mockNavigateTo.mock.calls[mockNavigateTo.mock.calls.length - 1];
637
+ expect(params.expression).toBe('memory:inuse_space:bytes:space:bytes{}');
638
+ expect(params).not.toHaveProperty('merge_from');
639
+ expect(params).not.toHaveProperty('merge_to');
589
640
  });
590
641
  });
591
642
 
592
643
  it('should preserve other URL parameters when updating', async () => {
593
- const {result} = renderHook(() => useQueryState(), {
594
- wrapper: createWrapper(
595
- {},
596
- '?expression=process_cpu:cpu:nanoseconds:cpu:nanoseconds:delta{}&other_param=value&unrelated=test'
597
- ),
598
- });
644
+ mockLocation.search =
645
+ '?expression=process_cpu:cpu:nanoseconds:cpu:nanoseconds:delta{}&other_param=value&unrelated=test';
646
+
647
+ const {result} = renderHook(() => useQueryState(), {wrapper: createWrapper()});
599
648
 
600
649
  // Update draft and commit
601
650
  act(() => {
@@ -607,21 +656,21 @@ describe('useQueryState', () => {
607
656
  });
608
657
 
609
658
  await waitFor(() => {
610
- expect(result.current.querySelection.expression).toBe(
611
- 'memory:inuse_space:bytes:space:bytes{}'
612
- );
659
+ expect(mockNavigateTo).toHaveBeenCalled();
660
+ const [, params] = mockNavigateTo.mock.calls[mockNavigateTo.mock.calls.length - 1];
661
+ expect(params.expression).toBe('memory:inuse_space:bytes:space:bytes{}');
662
+ expect(params.other_param).toBe('value');
663
+ expect(params.unrelated).toBe('test');
613
664
  });
614
665
  });
615
666
  });
616
667
 
617
668
  describe('Commit with refreshed time range (time range re-evaluation)', () => {
618
669
  it('should use refreshed time range values instead of draft state when provided', async () => {
619
- const {result} = renderHook(() => useQueryState(), {
620
- wrapper: createWrapper(
621
- {},
622
- '?expression=process_cpu:cpu:nanoseconds:cpu:nanoseconds{}&from=1000&to=2000&time_selection=relative:minute|15'
623
- ),
624
- });
670
+ mockLocation.search =
671
+ '?expression=process_cpu:cpu:nanoseconds:cpu:nanoseconds{}&from=1000&to=2000&time_selection=relative:minute|15';
672
+
673
+ const {result} = renderHook(() => useQueryState(), {wrapper: createWrapper()});
625
674
 
626
675
  // Draft state has original values
627
676
  expect(result.current.draftSelection.from).toBe(1000);
@@ -638,10 +687,12 @@ describe('useQueryState', () => {
638
687
  });
639
688
 
640
689
  await waitFor(() => {
690
+ expect(mockNavigateTo).toHaveBeenCalled();
691
+ const [, params] = mockNavigateTo.mock.calls[mockNavigateTo.mock.calls.length - 1];
641
692
  // Should use refreshed time range values, not draft values
642
- expect(String(result.current.querySelection.from)).toBe('5000');
643
- expect(String(result.current.querySelection.to)).toBe('6000');
644
- expect(result.current.querySelection.timeSelection).toBe('relative:minute|15');
693
+ expect(params.from).toBe('5000');
694
+ expect(params.to).toBe('6000');
695
+ expect(params.time_selection).toBe('relative:minute|15');
645
696
  });
646
697
  });
647
698
 
@@ -667,8 +718,7 @@ describe('useQueryState', () => {
667
718
  });
668
719
 
669
720
  await waitFor(() => {
670
- expect(String(result.current.querySelection.from)).toBe('3000');
671
- expect(String(result.current.querySelection.to)).toBe('4000');
721
+ expect(mockNavigateTo).toHaveBeenCalled();
672
722
  });
673
723
 
674
724
  // Draft state should be updated with the refreshed time range
@@ -677,12 +727,12 @@ describe('useQueryState', () => {
677
727
  });
678
728
 
679
729
  it('should trigger navigation even when expression unchanged (time re-evaluation)', async () => {
680
- const {result} = renderHook(() => useQueryState(), {
681
- wrapper: createWrapper(
682
- {},
683
- '?expression=process_cpu:cpu:nanoseconds:cpu:nanoseconds{}&from=1000&to=2000&time_selection=relative:minute|5'
684
- ),
685
- });
730
+ mockLocation.search =
731
+ '?expression=process_cpu:cpu:nanoseconds:cpu:nanoseconds{}&from=1000&to=2000&time_selection=relative:minute|5';
732
+
733
+ const {result} = renderHook(() => useQueryState(), {wrapper: createWrapper()});
734
+
735
+ mockNavigateTo.mockClear();
686
736
 
687
737
  // First commit with new time values
688
738
  act(() => {
@@ -694,10 +744,15 @@ describe('useQueryState', () => {
694
744
  });
695
745
 
696
746
  await waitFor(() => {
697
- expect(String(result.current.querySelection.from)).toBe('5000');
698
- expect(String(result.current.querySelection.to)).toBe('6000');
747
+ expect(mockNavigateTo).toHaveBeenCalledTimes(1);
699
748
  });
700
749
 
750
+ const firstCallParams = mockNavigateTo.mock.calls[0][1];
751
+ expect(firstCallParams.from).toBe('5000');
752
+ expect(firstCallParams.to).toBe('6000');
753
+
754
+ mockNavigateTo.mockClear();
755
+
701
756
  // Second commit with different time values (simulating clicking Search again)
702
757
  act(() => {
703
758
  result.current.commitDraft({
@@ -708,9 +763,15 @@ describe('useQueryState', () => {
708
763
  });
709
764
 
710
765
  await waitFor(() => {
711
- expect(String(result.current.querySelection.from)).toBe('7000');
712
- expect(String(result.current.querySelection.to)).toBe('8000');
766
+ expect(mockNavigateTo).toHaveBeenCalledTimes(1);
713
767
  });
768
+
769
+ const secondCallParams = mockNavigateTo.mock.calls[0][1];
770
+ expect(secondCallParams.from).toBe('7000');
771
+ expect(secondCallParams.to).toBe('8000');
772
+
773
+ // Verify that navigation was called both times despite expression being unchanged
774
+ expect(firstCallParams.from).not.toBe(secondCallParams.from);
714
775
  });
715
776
 
716
777
  it('should auto-calculate merge params for delta profiles when using refreshed time range', async () => {
@@ -734,19 +795,20 @@ describe('useQueryState', () => {
734
795
  });
735
796
 
736
797
  await waitFor(() => {
798
+ expect(mockNavigateTo).toHaveBeenCalled();
799
+ const [, params] = mockNavigateTo.mock.calls[mockNavigateTo.mock.calls.length - 1];
800
+
737
801
  // Verify merge params are calculated from refreshed time range
738
- expect(result.current.querySelection.mergeFrom).toBe('5000000000'); // 5000ms * 1_000_000
739
- expect(result.current.querySelection.mergeTo).toBe('6000000000'); // 6000ms * 1_000_000
802
+ expect(params.merge_from).toBe('5000000000'); // 5000ms * 1_000_000
803
+ expect(params.merge_to).toBe('6000000000'); // 6000ms * 1_000_000
740
804
  });
741
805
  });
742
806
 
743
807
  it('should use draft values when refreshedTimeRange is not provided', async () => {
744
- const {result} = renderHook(() => useQueryState(), {
745
- wrapper: createWrapper(
746
- {},
747
- '?expression=memory:inuse_space:bytes:space:bytes{}&from=1000&to=2000&time_selection=relative:hour|1'
748
- ),
749
- });
808
+ mockLocation.search =
809
+ '?expression=memory:inuse_space:bytes:space:bytes{}&from=1000&to=2000&time_selection=relative:hour|1';
810
+
811
+ const {result} = renderHook(() => useQueryState(), {wrapper: createWrapper()});
750
812
 
751
813
  // Change draft values
752
814
  act(() => {
@@ -759,10 +821,13 @@ describe('useQueryState', () => {
759
821
  });
760
822
 
761
823
  await waitFor(() => {
824
+ expect(mockNavigateTo).toHaveBeenCalled();
825
+ const [, params] = mockNavigateTo.mock.calls[mockNavigateTo.mock.calls.length - 1];
826
+
762
827
  // Should use updated draft values
763
- expect(String(result.current.querySelection.from)).toBe('3000');
764
- expect(String(result.current.querySelection.to)).toBe('4000');
765
- expect(result.current.querySelection.timeSelection).toBe('relative:minute|30');
828
+ expect(params.from).toBe('3000');
829
+ expect(params.to).toBe('4000');
830
+ expect(params.time_selection).toBe('relative:minute|30');
766
831
  });
767
832
  });
768
833
  });
@@ -770,11 +835,11 @@ describe('useQueryState', () => {
770
835
  describe('State persistence after page reload', () => {
771
836
  it('should retain committed values after page reload simulation', async () => {
772
837
  // Initial state (using delta profile since sumBy only applies to delta)
838
+ mockLocation.search =
839
+ '?expression=process_cpu:cpu:nanoseconds:cpu:nanoseconds:delta{}&from=1000&to=2000';
840
+
773
841
  const {result: result1, unmount} = renderHook(() => useQueryState(), {
774
- wrapper: createWrapper(
775
- {},
776
- '?expression=process_cpu:cpu:nanoseconds:cpu:nanoseconds:delta{}&from=1000&to=2000'
777
- ),
842
+ wrapper: createWrapper(),
778
843
  });
779
844
 
780
845
  // User makes changes to draft (using delta profile since sumBy only applies to delta)
@@ -790,30 +855,31 @@ describe('useQueryState', () => {
790
855
  });
791
856
 
792
857
  await waitFor(() => {
793
- expect(result1.current.querySelection.expression).toBe(
794
- 'memory:alloc_space:bytes:space:bytes:delta{}'
795
- );
858
+ expect(mockNavigateTo).toHaveBeenCalled();
796
859
  });
797
860
 
798
- // Build the query string from the committed state
861
+ // Get the params that were committed to URL
862
+ const committedParams = mockNavigateTo.mock.calls[mockNavigateTo.mock.calls.length - 1][1];
863
+
864
+ // Simulate page reload by updating mockLocation.search with committed values
799
865
  const queryString = new URLSearchParams({
800
- expression: String(result1.current.querySelection.expression),
801
- from: String(result1.current.querySelection.from),
802
- to: String(result1.current.querySelection.to),
803
- time_selection: String(result1.current.querySelection.timeSelection),
804
- sum_by: (result1.current.querySelection.sumBy ?? []).join(','),
866
+ expression: committedParams.expression as string,
867
+ from: committedParams.from as string,
868
+ to: committedParams.to as string,
869
+ time_selection: committedParams.time_selection as string,
870
+ sum_by: committedParams.sum_by as string,
805
871
  }).toString();
806
872
 
873
+ mockLocation.search = `?${queryString}`;
874
+
807
875
  // Unmount the old hook instance
808
876
  unmount();
809
877
 
810
878
  // Clear navigation mock to verify no new navigation on reload
811
879
  mockNavigateTo.mockClear();
812
880
 
813
- // Create new hook instance (simulating page reload) with the committed search params
814
- const {result: result2} = renderHook(() => useQueryState(), {
815
- wrapper: createWrapper({}, `?${queryString}`),
816
- });
881
+ // Create new hook instance (simulating page reload)
882
+ const {result: result2} = renderHook(() => useQueryState(), {wrapper: createWrapper()});
817
883
 
818
884
  // Verify state is loaded from URL after "reload"
819
885
  expect(result2.current.querySelection.expression).toBe(
@@ -822,6 +888,7 @@ describe('useQueryState', () => {
822
888
  expect(result2.current.querySelection.from).toBe(5000);
823
889
  expect(result2.current.querySelection.to).toBe(6000);
824
890
  expect(result2.current.querySelection.timeSelection).toBe('relative:minute|15');
891
+ expect(result2.current.querySelection.sumBy).toEqual(['namespace', 'pod']);
825
892
 
826
893
  // Draft should be synced with URL state on page load
827
894
  expect(result2.current.draftSelection.expression).toBe(
@@ -829,15 +896,19 @@ describe('useQueryState', () => {
829
896
  );
830
897
  expect(result2.current.draftSelection.from).toBe(5000);
831
898
  expect(result2.current.draftSelection.to).toBe(6000);
899
+ expect(result2.current.draftSelection.sumBy).toEqual(['namespace', 'pod']);
900
+
901
+ // No navigation should occur on page load
902
+ expect(mockNavigateTo).not.toHaveBeenCalled();
832
903
  });
833
904
 
834
905
  it('should preserve delta profile merge params after reload', async () => {
835
906
  // Initial state with delta profile
907
+ mockLocation.search =
908
+ '?expression=process_cpu:cpu:nanoseconds:cpu:nanoseconds:delta{}&from=1000&to=2000';
909
+
836
910
  const {result: result1, unmount} = renderHook(() => useQueryState(), {
837
- wrapper: createWrapper(
838
- {},
839
- '?expression=process_cpu:cpu:nanoseconds:cpu:nanoseconds:delta{}&from=1000&to=2000'
840
- ),
911
+ wrapper: createWrapper(),
841
912
  });
842
913
 
843
914
  // Commit with time override
@@ -850,27 +921,31 @@ describe('useQueryState', () => {
850
921
  });
851
922
 
852
923
  await waitFor(() => {
853
- expect(result1.current.querySelection.mergeFrom).toBe('5000000000');
854
- expect(result1.current.querySelection.mergeTo).toBe('6000000000');
924
+ expect(mockNavigateTo).toHaveBeenCalled();
855
925
  });
856
926
 
927
+ const committedParams = mockNavigateTo.mock.calls[mockNavigateTo.mock.calls.length - 1][1];
928
+
929
+ // Verify merge params were set
930
+ expect(committedParams.merge_from).toBe('5000000000');
931
+ expect(committedParams.merge_to).toBe('6000000000');
932
+
857
933
  // Simulate page reload with all params including merge params
858
934
  const queryString = new URLSearchParams({
859
- expression: String(result1.current.querySelection.expression),
860
- from: String(result1.current.querySelection.from),
861
- to: String(result1.current.querySelection.to),
862
- time_selection: String(result1.current.querySelection.timeSelection),
863
- merge_from: String(result1.current.querySelection.mergeFrom),
864
- merge_to: String(result1.current.querySelection.mergeTo),
935
+ expression: committedParams.expression as string,
936
+ from: committedParams.from as string,
937
+ to: committedParams.to as string,
938
+ time_selection: committedParams.time_selection as string,
939
+ merge_from: committedParams.merge_from as string,
940
+ merge_to: committedParams.merge_to as string,
865
941
  }).toString();
866
942
 
943
+ mockLocation.search = `?${queryString}`;
867
944
  unmount();
868
945
  mockNavigateTo.mockClear();
869
946
 
870
947
  // Create new hook instance
871
- const {result: result2} = renderHook(() => useQueryState(), {
872
- wrapper: createWrapper({}, `?${queryString}`),
873
- });
948
+ const {result: result2} = renderHook(() => useQueryState(), {wrapper: createWrapper()});
874
949
 
875
950
  // Verify merge params are preserved
876
951
  expect(result2.current.querySelection.mergeFrom).toBe('5000000000');
@@ -891,12 +966,10 @@ describe('useQueryState', () => {
891
966
 
892
967
  it('should compute ProfileSelection from URL params', () => {
893
968
  // Set URL with ProfileSelection params - using valid profile type
894
- const {result} = renderHook(() => useQueryState({suffix: '_a'}), {
895
- wrapper: createWrapper(
896
- {},
897
- '?merge_from_a=1234567890&merge_to_a=9876543210&selection_a=process_cpu:cpu:nanoseconds:cpu:nanoseconds:delta{pod="test"}'
898
- ),
899
- });
969
+ mockLocation.search =
970
+ '?merge_from_a=1234567890&merge_to_a=9876543210&selection_a=process_cpu:cpu:nanoseconds:cpu:nanoseconds:delta{pod="test"}';
971
+
972
+ const {result} = renderHook(() => useQueryState({suffix: '_a'}), {wrapper: createWrapper()});
900
973
 
901
974
  const {profileSelection} = result.current;
902
975
  expect(profileSelection).not.toBeNull();
@@ -933,14 +1006,13 @@ describe('useQueryState', () => {
933
1006
  });
934
1007
 
935
1008
  await waitFor(() => {
936
- const {profileSelection} = result.current;
937
- expect(profileSelection).not.toBeNull();
938
- const historyParams = profileSelection?.HistoryParams();
939
- expect(historyParams?.selection).toBe(
1009
+ expect(mockNavigateTo).toHaveBeenCalled();
1010
+ const [, params] = mockNavigateTo.mock.calls[mockNavigateTo.mock.calls.length - 1];
1011
+ expect(params.selection_a).toBe(
940
1012
  'memory:inuse_space:bytes:space:bytes{namespace="default"}'
941
1013
  );
942
- expect(historyParams?.merge_from).toBe('5000000000');
943
- expect(historyParams?.merge_to).toBe('6000000000');
1014
+ expect(params.merge_from_a).toBe('5000000000');
1015
+ expect(params.merge_to_a).toBe('6000000000');
944
1016
  });
945
1017
  });
946
1018
 
@@ -962,25 +1034,20 @@ describe('useQueryState', () => {
962
1034
  });
963
1035
 
964
1036
  await waitFor(() => {
965
- const {profileSelection} = resultB.current;
966
- expect(profileSelection).not.toBeNull();
967
- const historyParams = profileSelection?.HistoryParams();
968
- expect(historyParams?.selection).toBe(
969
- 'process_cpu:cpu:nanoseconds:cpu:nanoseconds{job="test"}'
970
- );
971
- expect(historyParams?.merge_from).toBe('7000000000');
972
- expect(historyParams?.merge_to).toBe('8000000000');
1037
+ expect(mockNavigateTo).toHaveBeenCalled();
1038
+ const [, params] = mockNavigateTo.mock.calls[mockNavigateTo.mock.calls.length - 1];
1039
+ expect(params.selection_b).toBe('process_cpu:cpu:nanoseconds:cpu:nanoseconds{job="test"}');
1040
+ expect(params.merge_from_b).toBe('7000000000');
1041
+ expect(params.merge_to_b).toBe('8000000000');
973
1042
  });
974
1043
  });
975
1044
 
976
1045
  it('should clear ProfileSelection when commitDraft is called', async () => {
977
1046
  // Start with a ProfileSelection in URL - using valid profile type
978
- const {result} = renderHook(() => useQueryState({suffix: '_a'}), {
979
- wrapper: createWrapper(
980
- {},
981
- '?expression_a=process_cpu:cpu:nanoseconds:cpu:nanoseconds{}&merge_from_a=1000000000&merge_to_a=2000000000&selection_a=process_cpu:cpu:nanoseconds:cpu:nanoseconds{pod="test"}'
982
- ),
983
- });
1047
+ mockLocation.search =
1048
+ '?expression_a=process_cpu:cpu:nanoseconds:cpu:nanoseconds{}&merge_from_a=1000000000&merge_to_a=2000000000&selection_a=process_cpu:cpu:nanoseconds:cpu:nanoseconds{pod="test"}';
1049
+
1050
+ const {result} = renderHook(() => useQueryState({suffix: '_a'}), {wrapper: createWrapper()});
984
1051
 
985
1052
  // Verify ProfileSelection exists
986
1053
  expect(result.current.profileSelection).not.toBeNull();
@@ -996,23 +1063,22 @@ describe('useQueryState', () => {
996
1063
  });
997
1064
 
998
1065
  await waitFor(() => {
999
- // ProfileSelection should be cleared
1000
- expect(result.current.profileSelection).toBeNull();
1066
+ expect(mockNavigateTo).toHaveBeenCalled();
1067
+ const [, params] = mockNavigateTo.mock.calls[mockNavigateTo.mock.calls.length - 1];
1068
+
1069
+ // ProfileSelection params should be cleared
1070
+ expect(params).not.toHaveProperty('selection_a');
1001
1071
 
1002
1072
  // But QuerySelection params should still be present
1003
- expect(result.current.querySelection.expression).toBe(
1004
- 'memory:inuse_space:bytes:space:bytes{}'
1005
- );
1073
+ expect(params.expression_a).toBe('memory:inuse_space:bytes:space:bytes{}');
1006
1074
  });
1007
1075
  });
1008
1076
 
1009
1077
  it('should handle ProfileSelection with delta profiles correctly', () => {
1010
- const {result} = renderHook(() => useQueryState({suffix: '_a'}), {
1011
- wrapper: createWrapper(
1012
- {},
1013
- '?merge_from_a=1000000000&merge_to_a=2000000000&selection_a=process_cpu:cpu:nanoseconds:cpu:nanoseconds:delta{node="worker"}'
1014
- ),
1015
- });
1078
+ mockLocation.search =
1079
+ '?merge_from_a=1000000000&merge_to_a=2000000000&selection_a=process_cpu:cpu:nanoseconds:cpu:nanoseconds:delta{node="worker"}';
1080
+
1081
+ const {result} = renderHook(() => useQueryState({suffix: '_a'}), {wrapper: createWrapper()});
1016
1082
 
1017
1083
  const {profileSelection} = result.current;
1018
1084
  expect(profileSelection).not.toBeNull();
@@ -1047,27 +1113,24 @@ describe('useQueryState', () => {
1047
1113
  });
1048
1114
 
1049
1115
  await waitFor(() => {
1050
- expect(result1.current.profileSelection).not.toBeNull();
1116
+ expect(mockNavigateTo).toHaveBeenCalled();
1051
1117
  });
1052
1118
 
1053
- // Get the committed state values to build reload URL
1054
- const historyParams = result1.current.profileSelection?.HistoryParams();
1055
- const selectionA = historyParams?.selection ?? '';
1056
- const mergeFromA = historyParams?.merge_from ?? '';
1057
- const mergeToA = historyParams?.merge_to ?? '';
1119
+ const committedParams = mockNavigateTo.mock.calls[mockNavigateTo.mock.calls.length - 1][1];
1058
1120
 
1121
+ // Simulate page reload by updating mockLocation.search
1122
+ const selectionA = String(committedParams.selection_a ?? '');
1123
+ const mergeFromA = String(committedParams.merge_from_a ?? '');
1124
+ const mergeToA = String(committedParams.merge_to_a ?? '');
1125
+ mockLocation.search = `?selection_a=${encodeURIComponent(
1126
+ selectionA
1127
+ )}&merge_from_a=${mergeFromA}&merge_to_a=${mergeToA}`;
1059
1128
  unmount();
1060
1129
  mockNavigateTo.mockClear();
1061
1130
 
1062
- // Create new hook instance (simulating page reload) with the committed search params
1131
+ // Create new hook instance (simulating page reload)
1063
1132
  const {result: result2} = renderHook(() => useQueryState({suffix: '_a'}), {
1064
- wrapper: createWrapper(
1065
- {},
1066
- // eslint-disable-next-line @typescript-eslint/restrict-template-expressions
1067
- `?selection_a=${encodeURIComponent(selectionA)}&merge_from_a=${
1068
- mergeFromA as string
1069
- }&merge_to_a=${mergeToA as string}`
1070
- ),
1133
+ wrapper: createWrapper(),
1071
1134
  });
1072
1135
 
1073
1136
  // Verify ProfileSelection is loaded from URL after reload
@@ -1076,12 +1139,13 @@ describe('useQueryState', () => {
1076
1139
 
1077
1140
  // Use interface methods to test
1078
1141
  expect(profileSelection?.Type()).toBe('merge');
1079
- const reloadedHistoryParams = profileSelection?.HistoryParams();
1080
- expect(reloadedHistoryParams?.merge_from).toBe('3000000000');
1081
- expect(reloadedHistoryParams?.merge_to).toBe('4000000000');
1082
- expect(reloadedHistoryParams?.selection).toBe(
1083
- 'memory:alloc_objects:count:space:bytes{pod="test"}'
1084
- );
1142
+ const historyParams = profileSelection?.HistoryParams();
1143
+ expect(historyParams?.merge_from).toBe('3000000000');
1144
+ expect(historyParams?.merge_to).toBe('4000000000');
1145
+ expect(historyParams?.selection).toBe('memory:alloc_objects:count:space:bytes{pod="test"}');
1146
+
1147
+ // No navigation should occur on page load
1148
+ expect(mockNavigateTo).not.toHaveBeenCalled();
1085
1149
  });
1086
1150
 
1087
1151
  it('should handle independent ProfileSelection for both sides in comparison mode', async () => {
@@ -1117,9 +1181,11 @@ describe('useQueryState', () => {
1117
1181
  });
1118
1182
 
1119
1183
  await waitFor(() => {
1120
- expect(result.current.stateA.profileSelection).not.toBeNull();
1184
+ expect(mockNavigateTo).toHaveBeenCalled();
1121
1185
  });
1122
1186
 
1187
+ mockNavigateTo.mockClear();
1188
+
1123
1189
  // Set ProfileSelection for side B
1124
1190
  act(() => {
1125
1191
  result.current.stateB.setProfileSelection(
@@ -1129,7 +1195,19 @@ describe('useQueryState', () => {
1129
1195
  );
1130
1196
  });
1131
1197
 
1132
- // Verify both ProfileSelections exist
1198
+ await waitFor(() => {
1199
+ expect(mockNavigateTo).toHaveBeenCalled();
1200
+ const [, params] = mockNavigateTo.mock.calls[mockNavigateTo.mock.calls.length - 1];
1201
+
1202
+ // Both selections should be in URL with different suffixes
1203
+ expect(params.selection_a).toBe('process_cpu:cpu:nanoseconds:cpu:nanoseconds{pod="app-a"}');
1204
+ expect(params.selection_b).toBe('process_cpu:cpu:nanoseconds:cpu:nanoseconds{pod="app-b"}');
1205
+ expect(params.merge_from_a).toBe('1000000000');
1206
+ expect(params.merge_from_b).toBe('3000000000');
1207
+ });
1208
+
1209
+ // The mockNavigateTo automatically updates mockLocation.search, so the URL change
1210
+ // should propagate to the hooks automatically. Verify both ProfileSelections exist.
1133
1211
  await waitFor(() => {
1134
1212
  expect(result.current.stateA.profileSelection).not.toBeNull();
1135
1213
  expect(result.current.stateB.profileSelection).not.toBeNull();
@@ -1138,9 +1216,9 @@ describe('useQueryState', () => {
1138
1216
 
1139
1217
  it('should return null ProfileSelection when only partial params exist', () => {
1140
1218
  // Missing selection param
1141
- const {result} = renderHook(() => useQueryState({suffix: '_a'}), {
1142
- wrapper: createWrapper({}, '?merge_from_a=1000000000&merge_to_a=2000000000'),
1143
- });
1219
+ mockLocation.search = '?merge_from_a=1000000000&merge_to_a=2000000000';
1220
+
1221
+ const {result} = renderHook(() => useQueryState({suffix: '_a'}), {wrapper: createWrapper()});
1144
1222
 
1145
1223
  expect(result.current.profileSelection).toBeNull();
1146
1224
  });
@@ -1159,12 +1237,10 @@ describe('useQueryState', () => {
1159
1237
  });
1160
1238
 
1161
1239
  await waitFor(() => {
1162
- const {profileSelection} = result.current;
1163
- expect(profileSelection).not.toBeNull();
1164
- const historyParams = profileSelection?.HistoryParams();
1165
- // The expression gets re-serialized through Query.parse which adds spaces after commas
1166
- expect(historyParams?.selection).toBe(
1167
- 'memory:alloc_objects:count:space:bytes:delta{namespace="default", pod="app-1", container="main"}'
1240
+ expect(mockNavigateTo).toHaveBeenCalled();
1241
+ const [, params] = mockNavigateTo.mock.calls[mockNavigateTo.mock.calls.length - 1];
1242
+ expect(params.selection_a).toBe(
1243
+ 'memory:alloc_objects:count:space:bytes:delta{namespace="default",pod="app-1",container="main"}'
1168
1244
  );
1169
1245
  });
1170
1246
  });
@@ -1183,24 +1259,20 @@ describe('useQueryState', () => {
1183
1259
  });
1184
1260
 
1185
1261
  await waitFor(() => {
1186
- const {profileSelection} = result.current;
1187
- expect(profileSelection).not.toBeNull();
1188
- const historyParams = profileSelection?.HistoryParams();
1189
- expect(historyParams?.selection).toBe(
1190
- 'process_cpu:cpu:nanoseconds:cpu:nanoseconds{job="test"}'
1191
- );
1192
- expect(historyParams?.merge_from).toBe('1000000000');
1193
- expect(historyParams?.merge_to).toBe('2000000000');
1262
+ // Should only navigate once despite setting 3 params (selection, merge_from, merge_to)
1263
+ expect(mockNavigateTo).toHaveBeenCalledTimes(1);
1264
+ const [, params] = mockNavigateTo.mock.calls[0];
1265
+ expect(params.selection_a).toBe('process_cpu:cpu:nanoseconds:cpu:nanoseconds{job="test"}');
1266
+ expect(params.merge_from_a).toBe('1000000000');
1267
+ expect(params.merge_to_a).toBe('2000000000');
1194
1268
  });
1195
1269
  });
1196
1270
 
1197
1271
  it('should preserve other URL params when setting ProfileSelection', async () => {
1198
- const {result} = renderHook(() => useQueryState({suffix: '_a'}), {
1199
- wrapper: createWrapper(
1200
- {},
1201
- '?expression_a=process_cpu:cpu:nanoseconds:cpu:nanoseconds{}&other_param=value&unrelated=test'
1202
- ),
1203
- });
1272
+ mockLocation.search =
1273
+ '?expression_a=process_cpu:cpu:nanoseconds:cpu:nanoseconds{}&other_param=value&unrelated=test';
1274
+
1275
+ const {result} = renderHook(() => useQueryState({suffix: '_a'}), {wrapper: createWrapper()});
1204
1276
 
1205
1277
  const mockQuery = {
1206
1278
  toString: () => 'process_cpu:cpu:nanoseconds:cpu:nanoseconds{pod="test"}',
@@ -1212,18 +1284,16 @@ describe('useQueryState', () => {
1212
1284
  });
1213
1285
 
1214
1286
  await waitFor(() => {
1287
+ expect(mockNavigateTo).toHaveBeenCalled();
1288
+ const [, params] = mockNavigateTo.mock.calls[mockNavigateTo.mock.calls.length - 1];
1289
+
1215
1290
  // ProfileSelection params should be set
1216
- const {profileSelection} = result.current;
1217
- expect(profileSelection).not.toBeNull();
1218
- const historyParams = profileSelection?.HistoryParams();
1219
- expect(historyParams?.selection).toBe(
1220
- 'process_cpu:cpu:nanoseconds:cpu:nanoseconds{pod="test"}'
1221
- );
1291
+ expect(params.selection_a).toBe('process_cpu:cpu:nanoseconds:cpu:nanoseconds{pod="test"}');
1222
1292
 
1223
- // Expression should still be present
1224
- expect(result.current.querySelection.expression).toBe(
1225
- 'process_cpu:cpu:nanoseconds:cpu:nanoseconds{}'
1226
- );
1293
+ // Other params should be preserved
1294
+ expect(params.expression_a).toBe('process_cpu:cpu:nanoseconds:cpu:nanoseconds{}');
1295
+ expect(params.other_param).toBe('value');
1296
+ expect(params.unrelated).toBe('test');
1227
1297
  });
1228
1298
  });
1229
1299
  });