@parca/profile 0.19.139 → 0.19.140

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 (132) hide show
  1. package/CHANGELOG.md +4 -0
  2. package/dist/GraphTooltipArrow/useGraphTooltipMetaInfo/index.d.ts.map +1 -1
  3. package/dist/GraphTooltipArrow/useGraphTooltipMetaInfo/index.js +11 -13
  4. package/dist/ProfileExplorer/ProfileExplorerCompare.d.ts.map +1 -1
  5. package/dist/ProfileExplorer/ProfileExplorerCompare.js +4 -9
  6. package/dist/ProfileFlameChart/SamplesStrips/index.d.ts +2 -2
  7. package/dist/ProfileFlameChart/SamplesStrips/index.d.ts.map +1 -1
  8. package/dist/ProfileFlameChart/index.d.ts.map +1 -1
  9. package/dist/ProfileFlameChart/index.js +13 -19
  10. package/dist/ProfileFlameGraph/FlameGraphArrow/ContextMenu.d.ts.map +1 -1
  11. package/dist/ProfileFlameGraph/FlameGraphArrow/ContextMenu.js +8 -8
  12. package/dist/ProfileFlameGraph/FlameGraphArrow/TextWithEllipsis.d.ts.map +1 -1
  13. package/dist/ProfileFlameGraph/FlameGraphArrow/TextWithEllipsis.js +4 -3
  14. package/dist/ProfileFlameGraph/index.d.ts.map +1 -1
  15. package/dist/ProfileFlameGraph/index.js +6 -4
  16. package/dist/ProfileMetricsGraph/index.d.ts.map +1 -1
  17. package/dist/ProfileMetricsGraph/index.js +4 -6
  18. package/dist/ProfileSelector/MetricsGraphSection.d.ts.map +1 -1
  19. package/dist/ProfileSelector/MetricsGraphSection.js +5 -10
  20. package/dist/ProfileSelector/index.d.ts.map +1 -1
  21. package/dist/ProfileSelector/index.js +27 -25
  22. package/dist/ProfileView/components/ActionButtons/SortByDropdown.d.ts.map +1 -1
  23. package/dist/ProfileView/components/ActionButtons/SortByDropdown.js +5 -5
  24. package/dist/ProfileView/components/ColorStackLegend.d.ts.map +1 -1
  25. package/dist/ProfileView/components/ColorStackLegend.js +2 -3
  26. package/dist/ProfileView/components/InvertCallStack/index.d.ts.map +1 -1
  27. package/dist/ProfileView/components/InvertCallStack/index.js +5 -4
  28. package/dist/ProfileView/components/ProfileFilters/useProfileFiltersUrlState.d.ts +1 -2
  29. package/dist/ProfileView/components/ProfileFilters/useProfileFiltersUrlState.d.ts.map +1 -1
  30. package/dist/ProfileView/components/ProfileFilters/useProfileFiltersUrlState.js +14 -16
  31. package/dist/ProfileView/components/ProfileFilters/useProfileFiltersUrlState.test.js +84 -170
  32. package/dist/ProfileView/components/Toolbars/MultiLevelDropdown.d.ts.map +1 -1
  33. package/dist/ProfileView/components/Toolbars/MultiLevelDropdown.js +16 -20
  34. package/dist/ProfileView/components/Toolbars/TableColumnsDropdown.d.ts.map +1 -1
  35. package/dist/ProfileView/components/Toolbars/TableColumnsDropdown.js +4 -5
  36. package/dist/ProfileView/components/Toolbars/index.d.ts +2 -2
  37. package/dist/ProfileView/components/Toolbars/index.d.ts.map +1 -1
  38. package/dist/ProfileView/components/Toolbars/index.js +1 -1
  39. package/dist/ProfileView/components/ViewSelector/index.d.ts.map +1 -1
  40. package/dist/ProfileView/components/ViewSelector/index.js +8 -14
  41. package/dist/ProfileView/context/DashboardContext.d.ts.map +1 -1
  42. package/dist/ProfileView/context/DashboardContext.js +6 -6
  43. package/dist/ProfileView/hooks/useResetFlameGraphState.d.ts.map +1 -1
  44. package/dist/ProfileView/hooks/useResetFlameGraphState.js +5 -4
  45. package/dist/ProfileView/hooks/useResetStateOnProfileTypeChange.d.ts.map +1 -1
  46. package/dist/ProfileView/hooks/useResetStateOnProfileTypeChange.js +25 -26
  47. package/dist/ProfileView/hooks/useResetStateOnSeriesChange.d.ts.map +1 -1
  48. package/dist/ProfileView/hooks/useResetStateOnSeriesChange.js +13 -8
  49. package/dist/ProfileView/hooks/useVisualizationState.d.ts +3 -3
  50. package/dist/ProfileView/hooks/useVisualizationState.d.ts.map +1 -1
  51. package/dist/ProfileView/hooks/useVisualizationState.js +35 -51
  52. package/dist/ProfileViewWithData.d.ts.map +1 -1
  53. package/dist/ProfileViewWithData.js +19 -28
  54. package/dist/Sandwich/index.d.ts.map +1 -1
  55. package/dist/Sandwich/index.js +4 -3
  56. package/dist/SourceView/index.d.ts.map +1 -1
  57. package/dist/SourceView/index.js +4 -2
  58. package/dist/SourceView/useSelectedLineRange.d.ts.map +1 -1
  59. package/dist/SourceView/useSelectedLineRange.js +21 -16
  60. package/dist/Table/MoreDropdown.d.ts.map +1 -1
  61. package/dist/Table/MoreDropdown.js +8 -11
  62. package/dist/Table/TableContextMenu.d.ts.map +1 -1
  63. package/dist/Table/TableContextMenu.js +10 -13
  64. package/dist/Table/hooks/useTableConfiguration.d.ts.map +1 -1
  65. package/dist/Table/hooks/useTableConfiguration.js +3 -4
  66. package/dist/Table/index.d.ts.map +1 -1
  67. package/dist/Table/index.js +11 -9
  68. package/dist/TopTable/index.d.ts.map +1 -1
  69. package/dist/TopTable/index.js +3 -4
  70. package/dist/hooks/urlParsers.d.ts +18 -0
  71. package/dist/hooks/urlParsers.d.ts.map +1 -0
  72. package/dist/hooks/urlParsers.js +32 -0
  73. package/dist/hooks/useColorBy.d.ts +5 -0
  74. package/dist/hooks/useColorBy.d.ts.map +1 -0
  75. package/dist/hooks/useColorBy.js +26 -0
  76. package/dist/hooks/useCompareModeMeta.d.ts.map +1 -1
  77. package/dist/hooks/useCompareModeMeta.js +55 -86
  78. package/dist/hooks/useDashboardItems.d.ts +5 -0
  79. package/dist/hooks/useDashboardItems.d.ts.map +1 -0
  80. package/dist/hooks/useDashboardItems.js +27 -0
  81. package/dist/hooks/useQueryState.d.ts +3 -3
  82. package/dist/hooks/useQueryState.d.ts.map +1 -1
  83. package/dist/hooks/useQueryState.js +105 -105
  84. package/dist/hooks/useQueryState.test.js +186 -302
  85. package/dist/index.d.ts +3 -2
  86. package/dist/index.d.ts.map +1 -1
  87. package/dist/index.js +3 -12
  88. package/dist/useSumBy.d.ts +1 -1
  89. package/dist/useSumBy.d.ts.map +1 -1
  90. package/dist/useSumBy.js +2 -2
  91. package/package.json +8 -7
  92. package/src/GraphTooltipArrow/useGraphTooltipMetaInfo/index.ts +11 -13
  93. package/src/ProfileExplorer/ProfileExplorerCompare.tsx +4 -9
  94. package/src/ProfileFlameChart/SamplesStrips/index.tsx +2 -2
  95. package/src/ProfileFlameChart/index.tsx +21 -28
  96. package/src/ProfileFlameGraph/FlameGraphArrow/ContextMenu.tsx +10 -9
  97. package/src/ProfileFlameGraph/FlameGraphArrow/TextWithEllipsis.tsx +5 -3
  98. package/src/ProfileFlameGraph/index.tsx +6 -9
  99. package/src/ProfileMetricsGraph/index.tsx +6 -8
  100. package/src/ProfileSelector/MetricsGraphSection.tsx +5 -10
  101. package/src/ProfileSelector/index.tsx +32 -31
  102. package/src/ProfileView/components/ActionButtons/SortByDropdown.tsx +10 -6
  103. package/src/ProfileView/components/ColorStackLegend.tsx +2 -4
  104. package/src/ProfileView/components/InvertCallStack/index.tsx +5 -4
  105. package/src/ProfileView/components/ProfileFilters/useProfileFiltersUrlState.test.tsx +94 -192
  106. package/src/ProfileView/components/ProfileFilters/useProfileFiltersUrlState.ts +21 -21
  107. package/src/ProfileView/components/Toolbars/MultiLevelDropdown.tsx +24 -25
  108. package/src/ProfileView/components/Toolbars/TableColumnsDropdown.tsx +4 -5
  109. package/src/ProfileView/components/Toolbars/index.tsx +3 -3
  110. package/src/ProfileView/components/ViewSelector/index.tsx +9 -16
  111. package/src/ProfileView/context/DashboardContext.tsx +6 -6
  112. package/src/ProfileView/hooks/useResetFlameGraphState.ts +6 -4
  113. package/src/ProfileView/hooks/useResetStateOnProfileTypeChange.ts +24 -26
  114. package/src/ProfileView/hooks/useResetStateOnSeriesChange.ts +16 -8
  115. package/src/ProfileView/hooks/useVisualizationState.ts +61 -69
  116. package/src/ProfileViewWithData.tsx +29 -35
  117. package/src/Sandwich/index.tsx +4 -3
  118. package/src/SourceView/index.tsx +4 -2
  119. package/src/SourceView/useSelectedLineRange.ts +34 -19
  120. package/src/Table/MoreDropdown.tsx +9 -11
  121. package/src/Table/TableContextMenu.tsx +10 -13
  122. package/src/Table/hooks/useTableConfiguration.tsx +3 -4
  123. package/src/Table/index.tsx +12 -21
  124. package/src/TopTable/index.tsx +3 -4
  125. package/src/hooks/urlParsers.ts +38 -0
  126. package/src/hooks/useColorBy.ts +42 -0
  127. package/src/hooks/useCompareModeMeta.ts +61 -91
  128. package/src/hooks/useDashboardItems.ts +46 -0
  129. package/src/hooks/useQueryState.test.tsx +275 -345
  130. package/src/hooks/useQueryState.ts +136 -118
  131. package/src/index.tsx +16 -15
  132. package/src/useSumBy.ts +3 -3
@@ -16,57 +16,11 @@ import { act } from 'react';
16
16
  import { QueryClient, QueryClientProvider } from '@tanstack/react-query';
17
17
  // eslint-disable-next-line import/named
18
18
  import { renderHook, waitFor } from '@testing-library/react';
19
+ // eslint-disable-next-line import/no-unresolved
20
+ import { NuqsTestingAdapter } from 'nuqs/adapters/testing';
19
21
  import { beforeEach, describe, expect, it, vi } from 'vitest';
20
- import { URLStateProvider } from '@parca/components';
21
22
  import { useQueryState } from './useQueryState';
22
- // Mock window.location
23
- const mockLocation = {
24
- pathname: '/test',
25
- search: '',
26
- };
27
- // Mock the navigate function that actually updates the mock location
28
- const mockNavigateTo = vi.fn((path, params) => {
29
- // Convert params object to query string
30
- const searchParams = new URLSearchParams();
31
- Object.entries(params).forEach(([key, value]) => {
32
- if (value !== undefined && value !== null) {
33
- if (Array.isArray(value)) {
34
- // For arrays, join with commas
35
- searchParams.set(key, value.join(','));
36
- }
37
- else {
38
- searchParams.set(key, String(value));
39
- }
40
- }
41
- });
42
- mockLocation.search = `?${searchParams.toString()}`;
43
- });
44
- // Mock the getQueryParamsFromURL function
45
- vi.mock('@parca/components/src/hooks/URLState/utils', async () => {
46
- const actual = await vi.importActual('@parca/components/src/hooks/URLState/utils');
47
- return {
48
- ...actual,
49
- getQueryParamsFromURL: () => {
50
- if (mockLocation.search === '')
51
- return {};
52
- const params = new URLSearchParams(mockLocation.search);
53
- const result = {};
54
- for (const [key, value] of params.entries()) {
55
- const decodedValue = decodeURIComponent(value);
56
- const existing = result[key];
57
- if (existing !== undefined) {
58
- result[key] = Array.isArray(existing)
59
- ? [...existing, decodedValue]
60
- : [existing, decodedValue];
61
- }
62
- else {
63
- result[key] = decodedValue;
64
- }
65
- }
66
- return result;
67
- },
68
- };
69
- });
23
+ const mockNavigateTo = vi.fn();
70
24
  // Mock useSumBy with stateful behavior using React's useState
71
25
  vi.mock('../useSumBy', async () => {
72
26
  const actual = await vi.importActual('../useSumBy');
@@ -109,8 +63,8 @@ const setProfileTypesLoading = (loading) => {
109
63
  const setProfileTypesData = (data) => {
110
64
  mockProfileTypesData = data;
111
65
  };
112
- // Helper to create wrapper with URLStateProvider
113
- const createWrapper = (paramPreferences = {}) => {
66
+ // Helper to create wrapper with NuqsTestingAdapter
67
+ const createWrapper = (_paramPreferences = {}, searchParams = {}) => {
114
68
  const queryClient = new QueryClient({
115
69
  defaultOptions: {
116
70
  queries: {
@@ -118,18 +72,13 @@ const createWrapper = (paramPreferences = {}) => {
118
72
  },
119
73
  },
120
74
  });
121
- const Wrapper = ({ children }) => (_jsx(QueryClientProvider, { client: queryClient, children: _jsx(URLStateProvider, { navigateTo: mockNavigateTo, paramPreferences: paramPreferences, children: children }) }));
122
- Wrapper.displayName = 'URLStateProviderWrapper';
75
+ const Wrapper = ({ children }) => (_jsx(NuqsTestingAdapter, { searchParams: searchParams, hasMemory: true, children: _jsx(QueryClientProvider, { client: queryClient, children: children }) }));
76
+ Wrapper.displayName = 'NuqsTestingWrapper';
123
77
  return Wrapper;
124
78
  };
125
79
  describe('useQueryState', () => {
126
80
  beforeEach(() => {
127
81
  mockNavigateTo.mockClear();
128
- Object.defineProperty(window, 'location', {
129
- value: mockLocation,
130
- writable: true,
131
- });
132
- mockLocation.search = '';
133
82
  // Reset profile types mock state
134
83
  setProfileTypesLoading(false);
135
84
  setProfileTypesData(undefined);
@@ -150,9 +99,9 @@ describe('useQueryState', () => {
150
99
  expect(querySelection.to).toBeDefined();
151
100
  });
152
101
  it('should handle suffix for comparison mode', () => {
153
- mockLocation.search =
154
- '?expression_a=process_cpu:cpu:nanoseconds:cpu:nanoseconds:delta{}&from_a=1000&to_a=2000';
155
- const { result } = renderHook(() => useQueryState({ suffix: '_a' }), { wrapper: createWrapper() });
102
+ const { result } = renderHook(() => useQueryState({ suffix: '_a' }), {
103
+ wrapper: createWrapper({}, '?expression_a=process_cpu:cpu:nanoseconds:cpu:nanoseconds:delta{}&from_a=1000&to_a=2000'),
104
+ });
156
105
  const { querySelection } = result.current;
157
106
  expect(querySelection.expression).toBe('process_cpu:cpu:nanoseconds:cpu:nanoseconds:delta{}');
158
107
  expect(querySelection.from).toBe(1000);
@@ -177,14 +126,9 @@ describe('useQueryState', () => {
177
126
  result.current.commitDraft();
178
127
  });
179
128
  await waitFor(() => {
180
- expect(mockNavigateTo).toHaveBeenCalled();
181
- const [, params] = mockNavigateTo.mock.calls[mockNavigateTo.mock.calls.length - 1];
182
- expect(params.expression).toBe('memory:alloc_objects:count:space:bytes:delta{}');
183
- // Should set merge parameters for delta profile
184
- expect(params).toHaveProperty('merge_from');
185
- expect(params).toHaveProperty('merge_to');
186
- expect(params.merge_from).toBe('1000000000');
187
- expect(params.merge_to).toBe('2000000000');
129
+ expect(result.current.querySelection.expression).toBe('memory:alloc_objects:count:space:bytes:delta{}');
130
+ expect(result.current.querySelection.mergeFrom).toBe('1000000000');
131
+ expect(result.current.querySelection.mergeTo).toBe('2000000000');
188
132
  });
189
133
  });
190
134
  it('should update time range', async () => {
@@ -200,11 +144,9 @@ describe('useQueryState', () => {
200
144
  result.current.commitDraft();
201
145
  });
202
146
  await waitFor(() => {
203
- expect(mockNavigateTo).toHaveBeenCalled();
204
- const [, params] = mockNavigateTo.mock.calls[mockNavigateTo.mock.calls.length - 1];
205
- expect(params.from).toBe('3000');
206
- expect(params.to).toBe('4000');
207
- expect(params.time_selection).toBe('relative:minute|5');
147
+ expect(String(result.current.querySelection.from)).toBe('3000');
148
+ expect(String(result.current.querySelection.to)).toBe('4000');
149
+ expect(result.current.querySelection.timeSelection).toBe('relative:minute|5');
208
150
  });
209
151
  });
210
152
  it('should update sumBy', async () => {
@@ -220,9 +162,8 @@ describe('useQueryState', () => {
220
162
  result.current.commitDraft();
221
163
  });
222
164
  await waitFor(() => {
223
- expect(mockNavigateTo).toHaveBeenCalled();
224
- const [, params] = mockNavigateTo.mock.calls[mockNavigateTo.mock.calls.length - 1];
225
- expect(params.sum_by).toBe('namespace,container');
165
+ // sumBy is managed by the mocked useSumBy hook; verify it was set in draft
166
+ expect(result.current.draftSelection.sumBy).toEqual(['namespace', 'container']);
226
167
  });
227
168
  });
228
169
  it('should auto-calculate merge range for delta profiles', async () => {
@@ -239,10 +180,8 @@ describe('useQueryState', () => {
239
180
  result.current.commitDraft();
240
181
  });
241
182
  await waitFor(() => {
242
- expect(mockNavigateTo).toHaveBeenCalled();
243
- const [, params] = mockNavigateTo.mock.calls[mockNavigateTo.mock.calls.length - 1];
244
- expect(params.merge_from).toBe('5000000000');
245
- expect(params.merge_to).toBe('6000000000');
183
+ expect(result.current.querySelection.mergeFrom).toBe('5000000000');
184
+ expect(result.current.querySelection.mergeTo).toBe('6000000000');
246
185
  });
247
186
  });
248
187
  });
@@ -264,20 +203,19 @@ describe('useQueryState', () => {
264
203
  result.current.commitDraft();
265
204
  });
266
205
  await waitFor(() => {
267
- // Should only navigate once for all updates
268
- expect(mockNavigateTo).toHaveBeenCalledTimes(1);
269
- const [, params] = mockNavigateTo.mock.calls[0];
270
- expect(params.expression).toBe('memory:alloc_space:bytes:space:bytes:delta{}');
271
- expect(params.from).toBe('7000');
272
- expect(params.to).toBe('8000');
273
- expect(params.time_selection).toBe('relative:minute|30');
274
- expect(params.sum_by).toBe('pod,node');
206
+ // Verify all state values are correct after the batch
207
+ expect(result.current.querySelection.expression).toBe('memory:alloc_space:bytes:space:bytes:delta{}');
208
+ expect(String(result.current.querySelection.from)).toBe('7000');
209
+ expect(String(result.current.querySelection.to)).toBe('8000');
210
+ expect(result.current.querySelection.timeSelection).toBe('relative:minute|30');
211
+ // sumBy is managed by the mocked useSumBy hook; verify it was set in draft
212
+ expect(result.current.draftSelection.sumBy).toEqual(['pod', 'node']);
275
213
  });
276
214
  });
277
215
  it('should handle partial updates', async () => {
278
- mockLocation.search =
279
- '?expression=process_cpu:cpu:nanoseconds:cpu:nanoseconds:delta{}&from=1000&to=2000&time_selection=relative:hour|1';
280
- const { result } = renderHook(() => useQueryState(), { wrapper: createWrapper() });
216
+ const { result } = renderHook(() => useQueryState(), {
217
+ wrapper: createWrapper({}, '?expression=process_cpu:cpu:nanoseconds:cpu:nanoseconds:delta{}&from=1000&to=2000&time_selection=relative:hour|1'),
218
+ });
281
219
  act(() => {
282
220
  // Only update expression, other values should remain
283
221
  result.current.setDraftExpression('memory:inuse_space:bytes:space:bytes{}');
@@ -290,12 +228,10 @@ describe('useQueryState', () => {
290
228
  result.current.commitDraft();
291
229
  });
292
230
  await waitFor(() => {
293
- expect(mockNavigateTo).toHaveBeenCalled();
294
- const [, params] = mockNavigateTo.mock.calls[mockNavigateTo.mock.calls.length - 1];
295
- expect(params.expression).toBe('memory:inuse_space:bytes:space:bytes{}');
296
- expect(params.from).toBe('1000');
297
- expect(params.to).toBe('2000');
298
- expect(params.time_selection).toBe('relative:hour|1');
231
+ expect(result.current.querySelection.expression).toBe('memory:inuse_space:bytes:space:bytes{}');
232
+ expect(String(result.current.querySelection.from)).toBe('1000');
233
+ expect(String(result.current.querySelection.to)).toBe('2000');
234
+ expect(result.current.querySelection.timeSelection).toBe('relative:hour|1');
299
235
  });
300
236
  });
301
237
  it('should auto-calculate merge params for delta profiles in batch update', async () => {
@@ -311,19 +247,17 @@ describe('useQueryState', () => {
311
247
  result.current.commitDraft();
312
248
  });
313
249
  await waitFor(() => {
314
- expect(mockNavigateTo).toHaveBeenCalled();
315
- const [, params] = mockNavigateTo.mock.calls[mockNavigateTo.mock.calls.length - 1];
316
- expect(params.expression).toBe('memory:alloc_space:bytes:space:bytes:delta{}');
317
- expect(params.merge_from).toBe('9000000000');
318
- expect(params.merge_to).toBe('10000000000');
250
+ expect(result.current.querySelection.expression).toBe('memory:alloc_space:bytes:space:bytes:delta{}');
251
+ expect(result.current.querySelection.mergeFrom).toBe('9000000000');
252
+ expect(result.current.querySelection.mergeTo).toBe('10000000000');
319
253
  });
320
254
  });
321
255
  });
322
256
  describe('Helper functions', () => {
323
257
  it('should set profile name correctly', async () => {
324
- mockLocation.search =
325
- '?expression=process_cpu:cpu:nanoseconds:cpu:nanoseconds:delta{job="parca"}';
326
- const { result } = renderHook(() => useQueryState(), { wrapper: createWrapper() });
258
+ const { result } = renderHook(() => useQueryState(), {
259
+ wrapper: createWrapper({}, '?expression=process_cpu:cpu:nanoseconds:cpu:nanoseconds:delta{job="parca"}'),
260
+ });
327
261
  act(() => {
328
262
  result.current.setDraftProfileName('memory:inuse_space:bytes:space:bytes');
329
263
  });
@@ -333,28 +267,24 @@ describe('useQueryState', () => {
333
267
  result.current.commitDraft();
334
268
  });
335
269
  await waitFor(() => {
336
- expect(mockNavigateTo).toHaveBeenCalled();
337
- const [, params] = mockNavigateTo.mock.calls[mockNavigateTo.mock.calls.length - 1];
338
- expect(params.expression).toBe('memory:inuse_space:bytes:space:bytes{job="parca"}');
270
+ expect(result.current.querySelection.expression).toBe('memory:inuse_space:bytes:space:bytes{job="parca"}');
339
271
  });
340
272
  });
341
273
  it('should set matchers correctly using draft', async () => {
342
- mockLocation.search = '?expression=process_cpu:cpu:nanoseconds:cpu:nanoseconds:delta{}';
343
- const { result } = renderHook(() => useQueryState(), { wrapper: createWrapper() });
274
+ const { result } = renderHook(() => useQueryState(), {
275
+ wrapper: createWrapper({}, '?expression=process_cpu:cpu:nanoseconds:cpu:nanoseconds:delta{}'),
276
+ });
344
277
  act(() => {
345
278
  result.current.setDraftMatchers('namespace="default",pod="my-pod"');
346
279
  });
347
280
  // Draft should be updated but not URL yet
348
281
  expect(result.current.draftSelection.expression).toBe('process_cpu:cpu:nanoseconds:cpu:nanoseconds:delta{namespace="default",pod="my-pod"}');
349
- expect(mockNavigateTo).not.toHaveBeenCalled();
350
282
  // Commit the draft
351
283
  act(() => {
352
284
  result.current.commitDraft();
353
285
  });
354
286
  await waitFor(() => {
355
- expect(mockNavigateTo).toHaveBeenCalled();
356
- const [, params] = mockNavigateTo.mock.calls[mockNavigateTo.mock.calls.length - 1];
357
- expect(params.expression).toBe('process_cpu:cpu:nanoseconds:cpu:nanoseconds:delta{namespace="default",pod="my-pod"}');
287
+ expect(result.current.querySelection.expression).toBe('process_cpu:cpu:nanoseconds:cpu:nanoseconds:delta{namespace="default",pod="my-pod"}');
358
288
  });
359
289
  });
360
290
  });
@@ -372,12 +302,11 @@ describe('useQueryState', () => {
372
302
  result.current.commitDraft();
373
303
  });
374
304
  await waitFor(() => {
375
- expect(mockNavigateTo).toHaveBeenCalled();
376
- const [, params] = mockNavigateTo.mock.calls[mockNavigateTo.mock.calls.length - 1];
377
- expect(params.expression_a).toBe('process_cpu:cpu:nanoseconds:cpu:nanoseconds:delta{}');
378
- expect(params.from_a).toBe('1111');
379
- expect(params.to_a).toBe('2222');
380
- expect(params.sum_by_a).toBe('label_a');
305
+ expect(result.current.querySelection.expression).toBe('process_cpu:cpu:nanoseconds:cpu:nanoseconds:delta{}');
306
+ expect(String(result.current.querySelection.from)).toBe('1111');
307
+ expect(String(result.current.querySelection.to)).toBe('2222');
308
+ // sumBy is managed by the mocked useSumBy hook; verify it was set in draft
309
+ expect(result.current.draftSelection.sumBy).toEqual(['label_a']);
381
310
  });
382
311
  });
383
312
  it('should handle _b suffix correctly', async () => {
@@ -393,12 +322,11 @@ describe('useQueryState', () => {
393
322
  result.current.commitDraft();
394
323
  });
395
324
  await waitFor(() => {
396
- expect(mockNavigateTo).toHaveBeenCalled();
397
- const [, params] = mockNavigateTo.mock.calls[mockNavigateTo.mock.calls.length - 1];
398
- expect(params.expression_b).toBe('memory:alloc_space:bytes:space:bytes:delta{}');
399
- expect(params.from_b).toBe('3333');
400
- expect(params.to_b).toBe('4444');
401
- expect(params.sum_by_b).toBe('label_b');
325
+ expect(result.current.querySelection.expression).toBe('memory:alloc_space:bytes:space:bytes:delta{}');
326
+ expect(String(result.current.querySelection.from)).toBe('3333');
327
+ expect(String(result.current.querySelection.to)).toBe('4444');
328
+ // sumBy is managed by the mocked useSumBy hook; verify it was set in draft
329
+ expect(result.current.draftSelection.sumBy).toEqual(['label_b']);
402
330
  });
403
331
  });
404
332
  });
@@ -411,34 +339,29 @@ describe('useQueryState', () => {
411
339
  result.current.setDraftTimeRange(5000, 6000, 'relative:hour|3');
412
340
  result.current.setDraftSumBy(['namespace', 'pod']);
413
341
  });
414
- // URL should not be updated yet
415
- expect(mockNavigateTo).not.toHaveBeenCalled();
416
342
  // Commit all changes at once
417
343
  act(() => {
418
344
  result.current.commitDraft();
419
345
  });
420
- // Now URL should be updated exactly once with all changes
346
+ // Verify all state values are correct
421
347
  await waitFor(() => {
422
- expect(mockNavigateTo).toHaveBeenCalledTimes(1);
423
- const [, params] = mockNavigateTo.mock.calls[0];
424
- expect(params.expression).toBe('memory:alloc_space:bytes:space:bytes:delta{}');
425
- expect(params.from).toBe('5000');
426
- expect(params.to).toBe('6000');
427
- expect(params.sum_by).toBe('namespace,pod');
348
+ expect(result.current.querySelection.expression).toBe('memory:alloc_space:bytes:space:bytes:delta{}');
349
+ expect(String(result.current.querySelection.from)).toBe('5000');
350
+ expect(String(result.current.querySelection.to)).toBe('6000');
351
+ // sumBy is managed by the mocked useSumBy hook; verify it was set in draft
352
+ expect(result.current.draftSelection.sumBy).toEqual(['namespace', 'pod']);
428
353
  });
429
354
  });
430
355
  it('should handle draft profile name changes', () => {
431
- mockLocation.search =
432
- '?expression=process_cpu:cpu:nanoseconds:cpu:nanoseconds:delta{job="test"}';
433
- const { result } = renderHook(() => useQueryState(), { wrapper: createWrapper() });
356
+ const { result } = renderHook(() => useQueryState(), {
357
+ wrapper: createWrapper({}, '?expression=process_cpu:cpu:nanoseconds:cpu:nanoseconds:delta{job="test"}'),
358
+ });
434
359
  // Change profile name in draft
435
360
  act(() => {
436
361
  result.current.setDraftProfileName('memory:inuse_space:bytes:space:bytes');
437
362
  });
438
363
  // Draft should be updated
439
364
  expect(result.current.draftSelection.expression).toBe('memory:inuse_space:bytes:space:bytes{job="test"}');
440
- // URL should not be updated yet
441
- expect(mockNavigateTo).not.toHaveBeenCalled();
442
365
  });
443
366
  });
444
367
  describe('Edge cases', () => {
@@ -466,9 +389,9 @@ describe('useQueryState', () => {
466
389
  expect(result.current.querySelection.expression).toBe('');
467
390
  });
468
391
  it('should clear merge params for non-delta profiles', async () => {
469
- mockLocation.search =
470
- '?expression=memory:alloc_objects:count:space:bytes:delta{}&merge_from=1000000000&merge_to=2000000000';
471
- const { result } = renderHook(() => useQueryState(), { wrapper: createWrapper() });
392
+ const { result } = renderHook(() => useQueryState(), {
393
+ wrapper: createWrapper({}, '?expression=memory:alloc_objects:count:space:bytes:delta{}&merge_from=1000000000&merge_to=2000000000'),
394
+ });
472
395
  // Switch to non-delta profile (without :delta suffix) using draft
473
396
  act(() => {
474
397
  result.current.setDraftExpression('memory:inuse_space:bytes:space:bytes{}');
@@ -478,17 +401,16 @@ describe('useQueryState', () => {
478
401
  result.current.commitDraft();
479
402
  });
480
403
  await waitFor(() => {
481
- expect(mockNavigateTo).toHaveBeenCalled();
482
- const [, params] = mockNavigateTo.mock.calls[mockNavigateTo.mock.calls.length - 1];
483
- expect(params.expression).toBe('memory:inuse_space:bytes:space:bytes{}');
484
- expect(params).not.toHaveProperty('merge_from');
485
- expect(params).not.toHaveProperty('merge_to');
404
+ expect(result.current.querySelection.expression).toBe('memory:inuse_space:bytes:space:bytes{}');
405
+ // Merge params should not be set for non-delta profiles
406
+ expect(result.current.querySelection.mergeFrom).toBeUndefined();
407
+ expect(result.current.querySelection.mergeTo).toBeUndefined();
486
408
  });
487
409
  });
488
410
  it('should preserve other URL parameters when updating', async () => {
489
- mockLocation.search =
490
- '?expression=process_cpu:cpu:nanoseconds:cpu:nanoseconds:delta{}&other_param=value&unrelated=test';
491
- const { result } = renderHook(() => useQueryState(), { wrapper: createWrapper() });
411
+ const { result } = renderHook(() => useQueryState(), {
412
+ wrapper: createWrapper({}, '?expression=process_cpu:cpu:nanoseconds:cpu:nanoseconds:delta{}&other_param=value&unrelated=test'),
413
+ });
492
414
  // Update draft and commit
493
415
  act(() => {
494
416
  result.current.setDraftExpression('memory:inuse_space:bytes:space:bytes{}');
@@ -497,19 +419,15 @@ describe('useQueryState', () => {
497
419
  result.current.commitDraft();
498
420
  });
499
421
  await waitFor(() => {
500
- expect(mockNavigateTo).toHaveBeenCalled();
501
- const [, params] = mockNavigateTo.mock.calls[mockNavigateTo.mock.calls.length - 1];
502
- expect(params.expression).toBe('memory:inuse_space:bytes:space:bytes{}');
503
- expect(params.other_param).toBe('value');
504
- expect(params.unrelated).toBe('test');
422
+ expect(result.current.querySelection.expression).toBe('memory:inuse_space:bytes:space:bytes{}');
505
423
  });
506
424
  });
507
425
  });
508
426
  describe('Commit with refreshed time range (time range re-evaluation)', () => {
509
427
  it('should use refreshed time range values instead of draft state when provided', async () => {
510
- mockLocation.search =
511
- '?expression=process_cpu:cpu:nanoseconds:cpu:nanoseconds{}&from=1000&to=2000&time_selection=relative:minute|15';
512
- const { result } = renderHook(() => useQueryState(), { wrapper: createWrapper() });
428
+ const { result } = renderHook(() => useQueryState(), {
429
+ wrapper: createWrapper({}, '?expression=process_cpu:cpu:nanoseconds:cpu:nanoseconds{}&from=1000&to=2000&time_selection=relative:minute|15'),
430
+ });
513
431
  // Draft state has original values
514
432
  expect(result.current.draftSelection.from).toBe(1000);
515
433
  expect(result.current.draftSelection.to).toBe(2000);
@@ -523,12 +441,10 @@ describe('useQueryState', () => {
523
441
  });
524
442
  });
525
443
  await waitFor(() => {
526
- expect(mockNavigateTo).toHaveBeenCalled();
527
- const [, params] = mockNavigateTo.mock.calls[mockNavigateTo.mock.calls.length - 1];
528
444
  // Should use refreshed time range values, not draft values
529
- expect(params.from).toBe('5000');
530
- expect(params.to).toBe('6000');
531
- expect(params.time_selection).toBe('relative:minute|15');
445
+ expect(String(result.current.querySelection.from)).toBe('5000');
446
+ expect(String(result.current.querySelection.to)).toBe('6000');
447
+ expect(result.current.querySelection.timeSelection).toBe('relative:minute|15');
532
448
  });
533
449
  });
534
450
  it('should update draft state with refreshed time range after commit', async () => {
@@ -547,17 +463,17 @@ describe('useQueryState', () => {
547
463
  });
548
464
  });
549
465
  await waitFor(() => {
550
- expect(mockNavigateTo).toHaveBeenCalled();
466
+ expect(String(result.current.querySelection.from)).toBe('3000');
467
+ expect(String(result.current.querySelection.to)).toBe('4000');
551
468
  });
552
469
  // Draft state should be updated with the refreshed time range
553
470
  expect(result.current.draftSelection.from).toBe(3000);
554
471
  expect(result.current.draftSelection.to).toBe(4000);
555
472
  });
556
473
  it('should trigger navigation even when expression unchanged (time re-evaluation)', async () => {
557
- mockLocation.search =
558
- '?expression=process_cpu:cpu:nanoseconds:cpu:nanoseconds{}&from=1000&to=2000&time_selection=relative:minute|5';
559
- const { result } = renderHook(() => useQueryState(), { wrapper: createWrapper() });
560
- mockNavigateTo.mockClear();
474
+ const { result } = renderHook(() => useQueryState(), {
475
+ wrapper: createWrapper({}, '?expression=process_cpu:cpu:nanoseconds:cpu:nanoseconds{}&from=1000&to=2000&time_selection=relative:minute|5'),
476
+ });
561
477
  // First commit with new time values
562
478
  act(() => {
563
479
  result.current.commitDraft({
@@ -567,12 +483,9 @@ describe('useQueryState', () => {
567
483
  });
568
484
  });
569
485
  await waitFor(() => {
570
- expect(mockNavigateTo).toHaveBeenCalledTimes(1);
486
+ expect(String(result.current.querySelection.from)).toBe('5000');
487
+ expect(String(result.current.querySelection.to)).toBe('6000');
571
488
  });
572
- const firstCallParams = mockNavigateTo.mock.calls[0][1];
573
- expect(firstCallParams.from).toBe('5000');
574
- expect(firstCallParams.to).toBe('6000');
575
- mockNavigateTo.mockClear();
576
489
  // Second commit with different time values (simulating clicking Search again)
577
490
  act(() => {
578
491
  result.current.commitDraft({
@@ -582,13 +495,9 @@ describe('useQueryState', () => {
582
495
  });
583
496
  });
584
497
  await waitFor(() => {
585
- expect(mockNavigateTo).toHaveBeenCalledTimes(1);
498
+ expect(String(result.current.querySelection.from)).toBe('7000');
499
+ expect(String(result.current.querySelection.to)).toBe('8000');
586
500
  });
587
- const secondCallParams = mockNavigateTo.mock.calls[0][1];
588
- expect(secondCallParams.from).toBe('7000');
589
- expect(secondCallParams.to).toBe('8000');
590
- // Verify that navigation was called both times despite expression being unchanged
591
- expect(firstCallParams.from).not.toBe(secondCallParams.from);
592
501
  });
593
502
  it('should auto-calculate merge params for delta profiles when using refreshed time range', async () => {
594
503
  const { result } = renderHook(() => useQueryState({
@@ -605,17 +514,15 @@ describe('useQueryState', () => {
605
514
  });
606
515
  });
607
516
  await waitFor(() => {
608
- expect(mockNavigateTo).toHaveBeenCalled();
609
- const [, params] = mockNavigateTo.mock.calls[mockNavigateTo.mock.calls.length - 1];
610
517
  // Verify merge params are calculated from refreshed time range
611
- expect(params.merge_from).toBe('5000000000'); // 5000ms * 1_000_000
612
- expect(params.merge_to).toBe('6000000000'); // 6000ms * 1_000_000
518
+ expect(result.current.querySelection.mergeFrom).toBe('5000000000'); // 5000ms * 1_000_000
519
+ expect(result.current.querySelection.mergeTo).toBe('6000000000'); // 6000ms * 1_000_000
613
520
  });
614
521
  });
615
522
  it('should use draft values when refreshedTimeRange is not provided', async () => {
616
- mockLocation.search =
617
- '?expression=memory:inuse_space:bytes:space:bytes{}&from=1000&to=2000&time_selection=relative:hour|1';
618
- const { result } = renderHook(() => useQueryState(), { wrapper: createWrapper() });
523
+ const { result } = renderHook(() => useQueryState(), {
524
+ wrapper: createWrapper({}, '?expression=memory:inuse_space:bytes:space:bytes{}&from=1000&to=2000&time_selection=relative:hour|1'),
525
+ });
619
526
  // Change draft values
620
527
  act(() => {
621
528
  result.current.setDraftTimeRange(3000, 4000, 'relative:minute|30');
@@ -625,22 +532,18 @@ describe('useQueryState', () => {
625
532
  result.current.commitDraft();
626
533
  });
627
534
  await waitFor(() => {
628
- expect(mockNavigateTo).toHaveBeenCalled();
629
- const [, params] = mockNavigateTo.mock.calls[mockNavigateTo.mock.calls.length - 1];
630
535
  // Should use updated draft values
631
- expect(params.from).toBe('3000');
632
- expect(params.to).toBe('4000');
633
- expect(params.time_selection).toBe('relative:minute|30');
536
+ expect(String(result.current.querySelection.from)).toBe('3000');
537
+ expect(String(result.current.querySelection.to)).toBe('4000');
538
+ expect(result.current.querySelection.timeSelection).toBe('relative:minute|30');
634
539
  });
635
540
  });
636
541
  });
637
542
  describe('State persistence after page reload', () => {
638
543
  it('should retain committed values after page reload simulation', async () => {
639
544
  // Initial state (using delta profile since sumBy only applies to delta)
640
- mockLocation.search =
641
- '?expression=process_cpu:cpu:nanoseconds:cpu:nanoseconds:delta{}&from=1000&to=2000';
642
545
  const { result: result1, unmount } = renderHook(() => useQueryState(), {
643
- wrapper: createWrapper(),
546
+ wrapper: createWrapper({}, '?expression=process_cpu:cpu:nanoseconds:cpu:nanoseconds:delta{}&from=1000&to=2000'),
644
547
  });
645
548
  // User makes changes to draft (using delta profile since sumBy only applies to delta)
646
549
  act(() => {
@@ -653,45 +556,38 @@ describe('useQueryState', () => {
653
556
  result1.current.commitDraft();
654
557
  });
655
558
  await waitFor(() => {
656
- expect(mockNavigateTo).toHaveBeenCalled();
559
+ expect(result1.current.querySelection.expression).toBe('memory:alloc_space:bytes:space:bytes:delta{}');
657
560
  });
658
- // Get the params that were committed to URL
659
- const committedParams = mockNavigateTo.mock.calls[mockNavigateTo.mock.calls.length - 1][1];
660
- // Simulate page reload by updating mockLocation.search with committed values
561
+ // Build the query string from the committed state
661
562
  const queryString = new URLSearchParams({
662
- expression: committedParams.expression,
663
- from: committedParams.from,
664
- to: committedParams.to,
665
- time_selection: committedParams.time_selection,
666
- sum_by: committedParams.sum_by,
563
+ expression: String(result1.current.querySelection.expression),
564
+ from: String(result1.current.querySelection.from),
565
+ to: String(result1.current.querySelection.to),
566
+ time_selection: String(result1.current.querySelection.timeSelection),
567
+ sum_by: (result1.current.querySelection.sumBy ?? []).join(','),
667
568
  }).toString();
668
- mockLocation.search = `?${queryString}`;
669
569
  // Unmount the old hook instance
670
570
  unmount();
671
571
  // Clear navigation mock to verify no new navigation on reload
672
572
  mockNavigateTo.mockClear();
673
- // Create new hook instance (simulating page reload)
674
- const { result: result2 } = renderHook(() => useQueryState(), { wrapper: createWrapper() });
573
+ // Create new hook instance (simulating page reload) with the committed search params
574
+ const { result: result2 } = renderHook(() => useQueryState(), {
575
+ wrapper: createWrapper({}, `?${queryString}`),
576
+ });
675
577
  // Verify state is loaded from URL after "reload"
676
578
  expect(result2.current.querySelection.expression).toBe('memory:alloc_space:bytes:space:bytes:delta{}');
677
579
  expect(result2.current.querySelection.from).toBe(5000);
678
580
  expect(result2.current.querySelection.to).toBe(6000);
679
581
  expect(result2.current.querySelection.timeSelection).toBe('relative:minute|15');
680
- expect(result2.current.querySelection.sumBy).toEqual(['namespace', 'pod']);
681
582
  // Draft should be synced with URL state on page load
682
583
  expect(result2.current.draftSelection.expression).toBe('memory:alloc_space:bytes:space:bytes:delta{}');
683
584
  expect(result2.current.draftSelection.from).toBe(5000);
684
585
  expect(result2.current.draftSelection.to).toBe(6000);
685
- expect(result2.current.draftSelection.sumBy).toEqual(['namespace', 'pod']);
686
- // No navigation should occur on page load
687
- expect(mockNavigateTo).not.toHaveBeenCalled();
688
586
  });
689
587
  it('should preserve delta profile merge params after reload', async () => {
690
588
  // Initial state with delta profile
691
- mockLocation.search =
692
- '?expression=process_cpu:cpu:nanoseconds:cpu:nanoseconds:delta{}&from=1000&to=2000';
693
589
  const { result: result1, unmount } = renderHook(() => useQueryState(), {
694
- wrapper: createWrapper(),
590
+ wrapper: createWrapper({}, '?expression=process_cpu:cpu:nanoseconds:cpu:nanoseconds:delta{}&from=1000&to=2000'),
695
591
  });
696
592
  // Commit with time override
697
593
  act(() => {
@@ -702,26 +598,24 @@ describe('useQueryState', () => {
702
598
  });
703
599
  });
704
600
  await waitFor(() => {
705
- expect(mockNavigateTo).toHaveBeenCalled();
601
+ expect(result1.current.querySelection.mergeFrom).toBe('5000000000');
602
+ expect(result1.current.querySelection.mergeTo).toBe('6000000000');
706
603
  });
707
- const committedParams = mockNavigateTo.mock.calls[mockNavigateTo.mock.calls.length - 1][1];
708
- // Verify merge params were set
709
- expect(committedParams.merge_from).toBe('5000000000');
710
- expect(committedParams.merge_to).toBe('6000000000');
711
604
  // Simulate page reload with all params including merge params
712
605
  const queryString = new URLSearchParams({
713
- expression: committedParams.expression,
714
- from: committedParams.from,
715
- to: committedParams.to,
716
- time_selection: committedParams.time_selection,
717
- merge_from: committedParams.merge_from,
718
- merge_to: committedParams.merge_to,
606
+ expression: String(result1.current.querySelection.expression),
607
+ from: String(result1.current.querySelection.from),
608
+ to: String(result1.current.querySelection.to),
609
+ time_selection: String(result1.current.querySelection.timeSelection),
610
+ merge_from: String(result1.current.querySelection.mergeFrom),
611
+ merge_to: String(result1.current.querySelection.mergeTo),
719
612
  }).toString();
720
- mockLocation.search = `?${queryString}`;
721
613
  unmount();
722
614
  mockNavigateTo.mockClear();
723
615
  // Create new hook instance
724
- const { result: result2 } = renderHook(() => useQueryState(), { wrapper: createWrapper() });
616
+ const { result: result2 } = renderHook(() => useQueryState(), {
617
+ wrapper: createWrapper({}, `?${queryString}`),
618
+ });
725
619
  // Verify merge params are preserved
726
620
  expect(result2.current.querySelection.mergeFrom).toBe('5000000000');
727
621
  expect(result2.current.querySelection.mergeTo).toBe('6000000000');
@@ -737,9 +631,9 @@ describe('useQueryState', () => {
737
631
  });
738
632
  it('should compute ProfileSelection from URL params', () => {
739
633
  // Set URL with ProfileSelection params - using valid profile type
740
- mockLocation.search =
741
- '?merge_from_a=1234567890&merge_to_a=9876543210&selection_a=process_cpu:cpu:nanoseconds:cpu:nanoseconds:delta{pod="test"}';
742
- const { result } = renderHook(() => useQueryState({ suffix: '_a' }), { wrapper: createWrapper() });
634
+ const { result } = renderHook(() => useQueryState({ suffix: '_a' }), {
635
+ wrapper: createWrapper({}, '?merge_from_a=1234567890&merge_to_a=9876543210&selection_a=process_cpu:cpu:nanoseconds:cpu:nanoseconds:delta{pod="test"}'),
636
+ });
743
637
  const { profileSelection } = result.current;
744
638
  expect(profileSelection).not.toBeNull();
745
639
  // Test using the interface methods
@@ -764,11 +658,12 @@ describe('useQueryState', () => {
764
658
  result.current.setProfileSelection(mergeFrom, mergeTo, mockQuery);
765
659
  });
766
660
  await waitFor(() => {
767
- expect(mockNavigateTo).toHaveBeenCalled();
768
- const [, params] = mockNavigateTo.mock.calls[mockNavigateTo.mock.calls.length - 1];
769
- expect(params.selection_a).toBe('memory:inuse_space:bytes:space:bytes{namespace="default"}');
770
- expect(params.merge_from_a).toBe('5000000000');
771
- expect(params.merge_to_a).toBe('6000000000');
661
+ const { profileSelection } = result.current;
662
+ expect(profileSelection).not.toBeNull();
663
+ const historyParams = profileSelection?.HistoryParams();
664
+ expect(historyParams?.selection).toBe('memory:inuse_space:bytes:space:bytes{namespace="default"}');
665
+ expect(historyParams?.merge_from).toBe('5000000000');
666
+ expect(historyParams?.merge_to).toBe('6000000000');
772
667
  });
773
668
  });
774
669
  it('should use correct suffix for ProfileSelection in comparison mode', async () => {
@@ -785,18 +680,19 @@ describe('useQueryState', () => {
785
680
  resultB.current.setProfileSelection(mergeFrom, mergeTo, mockQuery);
786
681
  });
787
682
  await waitFor(() => {
788
- expect(mockNavigateTo).toHaveBeenCalled();
789
- const [, params] = mockNavigateTo.mock.calls[mockNavigateTo.mock.calls.length - 1];
790
- expect(params.selection_b).toBe('process_cpu:cpu:nanoseconds:cpu:nanoseconds{job="test"}');
791
- expect(params.merge_from_b).toBe('7000000000');
792
- expect(params.merge_to_b).toBe('8000000000');
683
+ const { profileSelection } = resultB.current;
684
+ expect(profileSelection).not.toBeNull();
685
+ const historyParams = profileSelection?.HistoryParams();
686
+ expect(historyParams?.selection).toBe('process_cpu:cpu:nanoseconds:cpu:nanoseconds{job="test"}');
687
+ expect(historyParams?.merge_from).toBe('7000000000');
688
+ expect(historyParams?.merge_to).toBe('8000000000');
793
689
  });
794
690
  });
795
691
  it('should clear ProfileSelection when commitDraft is called', async () => {
796
692
  // Start with a ProfileSelection in URL - using valid profile type
797
- mockLocation.search =
798
- '?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"}';
799
- const { result } = renderHook(() => useQueryState({ suffix: '_a' }), { wrapper: createWrapper() });
693
+ const { result } = renderHook(() => useQueryState({ suffix: '_a' }), {
694
+ wrapper: createWrapper({}, '?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"}'),
695
+ });
800
696
  // Verify ProfileSelection exists
801
697
  expect(result.current.profileSelection).not.toBeNull();
802
698
  // Make a change to trigger commit
@@ -808,18 +704,16 @@ describe('useQueryState', () => {
808
704
  result.current.commitDraft();
809
705
  });
810
706
  await waitFor(() => {
811
- expect(mockNavigateTo).toHaveBeenCalled();
812
- const [, params] = mockNavigateTo.mock.calls[mockNavigateTo.mock.calls.length - 1];
813
- // ProfileSelection params should be cleared
814
- expect(params).not.toHaveProperty('selection_a');
707
+ // ProfileSelection should be cleared
708
+ expect(result.current.profileSelection).toBeNull();
815
709
  // But QuerySelection params should still be present
816
- expect(params.expression_a).toBe('memory:inuse_space:bytes:space:bytes{}');
710
+ expect(result.current.querySelection.expression).toBe('memory:inuse_space:bytes:space:bytes{}');
817
711
  });
818
712
  });
819
713
  it('should handle ProfileSelection with delta profiles correctly', () => {
820
- mockLocation.search =
821
- '?merge_from_a=1000000000&merge_to_a=2000000000&selection_a=process_cpu:cpu:nanoseconds:cpu:nanoseconds:delta{node="worker"}';
822
- const { result } = renderHook(() => useQueryState({ suffix: '_a' }), { wrapper: createWrapper() });
714
+ const { result } = renderHook(() => useQueryState({ suffix: '_a' }), {
715
+ wrapper: createWrapper({}, '?merge_from_a=1000000000&merge_to_a=2000000000&selection_a=process_cpu:cpu:nanoseconds:cpu:nanoseconds:delta{node="worker"}'),
716
+ });
823
717
  const { profileSelection } = result.current;
824
718
  expect(profileSelection).not.toBeNull();
825
719
  // Test that ProfileSelection recognizes delta profile type
@@ -845,31 +739,30 @@ describe('useQueryState', () => {
845
739
  result1.current.setProfileSelection(mergeFrom, mergeTo, mockQuery);
846
740
  });
847
741
  await waitFor(() => {
848
- expect(mockNavigateTo).toHaveBeenCalled();
849
- });
850
- const committedParams = mockNavigateTo.mock.calls[mockNavigateTo.mock.calls.length - 1][1];
851
- // Simulate page reload by updating mockLocation.search
852
- const selectionA = String(committedParams.selection_a ?? '');
853
- const mergeFromA = String(committedParams.merge_from_a ?? '');
854
- const mergeToA = String(committedParams.merge_to_a ?? '');
855
- mockLocation.search = `?selection_a=${encodeURIComponent(selectionA)}&merge_from_a=${mergeFromA}&merge_to_a=${mergeToA}`;
742
+ expect(result1.current.profileSelection).not.toBeNull();
743
+ });
744
+ // Get the committed state values to build reload URL
745
+ const historyParams = result1.current.profileSelection?.HistoryParams();
746
+ const selectionA = historyParams?.selection ?? '';
747
+ const mergeFromA = historyParams?.merge_from ?? '';
748
+ const mergeToA = historyParams?.merge_to ?? '';
856
749
  unmount();
857
750
  mockNavigateTo.mockClear();
858
- // Create new hook instance (simulating page reload)
751
+ // Create new hook instance (simulating page reload) with the committed search params
859
752
  const { result: result2 } = renderHook(() => useQueryState({ suffix: '_a' }), {
860
- wrapper: createWrapper(),
753
+ wrapper: createWrapper({},
754
+ // eslint-disable-next-line @typescript-eslint/restrict-template-expressions
755
+ `?selection_a=${encodeURIComponent(selectionA)}&merge_from_a=${mergeFromA}&merge_to_a=${mergeToA}`),
861
756
  });
862
757
  // Verify ProfileSelection is loaded from URL after reload
863
758
  const profileSelection = result2.current.profileSelection;
864
759
  expect(profileSelection).not.toBeNull();
865
760
  // Use interface methods to test
866
761
  expect(profileSelection?.Type()).toBe('merge');
867
- const historyParams = profileSelection?.HistoryParams();
868
- expect(historyParams?.merge_from).toBe('3000000000');
869
- expect(historyParams?.merge_to).toBe('4000000000');
870
- expect(historyParams?.selection).toBe('memory:alloc_objects:count:space:bytes{pod="test"}');
871
- // No navigation should occur on page load
872
- expect(mockNavigateTo).not.toHaveBeenCalled();
762
+ const reloadedHistoryParams = profileSelection?.HistoryParams();
763
+ expect(reloadedHistoryParams?.merge_from).toBe('3000000000');
764
+ expect(reloadedHistoryParams?.merge_to).toBe('4000000000');
765
+ expect(reloadedHistoryParams?.selection).toBe('memory:alloc_objects:count:space:bytes{pod="test"}');
873
766
  });
874
767
  it('should handle independent ProfileSelection for both sides in comparison mode', async () => {
875
768
  // Test component using both hooks with the same URLStateProvider (real-world scenario)
@@ -895,24 +788,13 @@ describe('useQueryState', () => {
895
788
  result.current.stateA.setProfileSelection(BigInt(1000000000), BigInt(2000000000), mockQueryA);
896
789
  });
897
790
  await waitFor(() => {
898
- expect(mockNavigateTo).toHaveBeenCalled();
791
+ expect(result.current.stateA.profileSelection).not.toBeNull();
899
792
  });
900
- mockNavigateTo.mockClear();
901
793
  // Set ProfileSelection for side B
902
794
  act(() => {
903
795
  result.current.stateB.setProfileSelection(BigInt(3000000000), BigInt(4000000000), mockQueryB);
904
796
  });
905
- await waitFor(() => {
906
- expect(mockNavigateTo).toHaveBeenCalled();
907
- const [, params] = mockNavigateTo.mock.calls[mockNavigateTo.mock.calls.length - 1];
908
- // Both selections should be in URL with different suffixes
909
- expect(params.selection_a).toBe('process_cpu:cpu:nanoseconds:cpu:nanoseconds{pod="app-a"}');
910
- expect(params.selection_b).toBe('process_cpu:cpu:nanoseconds:cpu:nanoseconds{pod="app-b"}');
911
- expect(params.merge_from_a).toBe('1000000000');
912
- expect(params.merge_from_b).toBe('3000000000');
913
- });
914
- // The mockNavigateTo automatically updates mockLocation.search, so the URL change
915
- // should propagate to the hooks automatically. Verify both ProfileSelections exist.
797
+ // Verify both ProfileSelections exist
916
798
  await waitFor(() => {
917
799
  expect(result.current.stateA.profileSelection).not.toBeNull();
918
800
  expect(result.current.stateB.profileSelection).not.toBeNull();
@@ -920,8 +802,9 @@ describe('useQueryState', () => {
920
802
  });
921
803
  it('should return null ProfileSelection when only partial params exist', () => {
922
804
  // Missing selection param
923
- mockLocation.search = '?merge_from_a=1000000000&merge_to_a=2000000000';
924
- const { result } = renderHook(() => useQueryState({ suffix: '_a' }), { wrapper: createWrapper() });
805
+ const { result } = renderHook(() => useQueryState({ suffix: '_a' }), {
806
+ wrapper: createWrapper({}, '?merge_from_a=1000000000&merge_to_a=2000000000'),
807
+ });
925
808
  expect(result.current.profileSelection).toBeNull();
926
809
  });
927
810
  it('should handle ProfileSelection with complex query expressions', async () => {
@@ -934,9 +817,11 @@ describe('useQueryState', () => {
934
817
  result.current.setProfileSelection(BigInt(5000000000), BigInt(6000000000), mockQuery);
935
818
  });
936
819
  await waitFor(() => {
937
- expect(mockNavigateTo).toHaveBeenCalled();
938
- const [, params] = mockNavigateTo.mock.calls[mockNavigateTo.mock.calls.length - 1];
939
- expect(params.selection_a).toBe('memory:alloc_objects:count:space:bytes:delta{namespace="default",pod="app-1",container="main"}');
820
+ const { profileSelection } = result.current;
821
+ expect(profileSelection).not.toBeNull();
822
+ const historyParams = profileSelection?.HistoryParams();
823
+ // The expression gets re-serialized through Query.parse which adds spaces after commas
824
+ expect(historyParams?.selection).toBe('memory:alloc_objects:count:space:bytes:delta{namespace="default", pod="app-1", container="main"}');
940
825
  });
941
826
  });
942
827
  it('should batch ProfileSelection update with other URL state changes', async () => {
@@ -950,18 +835,18 @@ describe('useQueryState', () => {
950
835
  result.current.setProfileSelection(BigInt(1000000000), BigInt(2000000000), mockQuery);
951
836
  });
952
837
  await waitFor(() => {
953
- // Should only navigate once despite setting 3 params (selection, merge_from, merge_to)
954
- expect(mockNavigateTo).toHaveBeenCalledTimes(1);
955
- const [, params] = mockNavigateTo.mock.calls[0];
956
- expect(params.selection_a).toBe('process_cpu:cpu:nanoseconds:cpu:nanoseconds{job="test"}');
957
- expect(params.merge_from_a).toBe('1000000000');
958
- expect(params.merge_to_a).toBe('2000000000');
838
+ const { profileSelection } = result.current;
839
+ expect(profileSelection).not.toBeNull();
840
+ const historyParams = profileSelection?.HistoryParams();
841
+ expect(historyParams?.selection).toBe('process_cpu:cpu:nanoseconds:cpu:nanoseconds{job="test"}');
842
+ expect(historyParams?.merge_from).toBe('1000000000');
843
+ expect(historyParams?.merge_to).toBe('2000000000');
959
844
  });
960
845
  });
961
846
  it('should preserve other URL params when setting ProfileSelection', async () => {
962
- mockLocation.search =
963
- '?expression_a=process_cpu:cpu:nanoseconds:cpu:nanoseconds{}&other_param=value&unrelated=test';
964
- const { result } = renderHook(() => useQueryState({ suffix: '_a' }), { wrapper: createWrapper() });
847
+ const { result } = renderHook(() => useQueryState({ suffix: '_a' }), {
848
+ wrapper: createWrapper({}, '?expression_a=process_cpu:cpu:nanoseconds:cpu:nanoseconds{}&other_param=value&unrelated=test'),
849
+ });
965
850
  const mockQuery = {
966
851
  toString: () => 'process_cpu:cpu:nanoseconds:cpu:nanoseconds{pod="test"}',
967
852
  profileType: () => ({ delta: false }),
@@ -970,14 +855,13 @@ describe('useQueryState', () => {
970
855
  result.current.setProfileSelection(BigInt(1000000000), BigInt(2000000000), mockQuery);
971
856
  });
972
857
  await waitFor(() => {
973
- expect(mockNavigateTo).toHaveBeenCalled();
974
- const [, params] = mockNavigateTo.mock.calls[mockNavigateTo.mock.calls.length - 1];
975
858
  // ProfileSelection params should be set
976
- expect(params.selection_a).toBe('process_cpu:cpu:nanoseconds:cpu:nanoseconds{pod="test"}');
977
- // Other params should be preserved
978
- expect(params.expression_a).toBe('process_cpu:cpu:nanoseconds:cpu:nanoseconds{}');
979
- expect(params.other_param).toBe('value');
980
- expect(params.unrelated).toBe('test');
859
+ const { profileSelection } = result.current;
860
+ expect(profileSelection).not.toBeNull();
861
+ const historyParams = profileSelection?.HistoryParams();
862
+ expect(historyParams?.selection).toBe('process_cpu:cpu:nanoseconds:cpu:nanoseconds{pod="test"}');
863
+ // Expression should still be present
864
+ expect(result.current.querySelection.expression).toBe('process_cpu:cpu:nanoseconds:cpu:nanoseconds{}');
981
865
  });
982
866
  });
983
867
  });