@parca/profile 0.19.142 → 0.19.143

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 (135) 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 +22 -28
  4. package/dist/ProfileExplorer/ProfileExplorerCompare.d.ts.map +1 -1
  5. package/dist/ProfileExplorer/ProfileExplorerCompare.js +72 -73
  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 +20 -24
  10. package/dist/ProfileFlameGraph/FlameGraphArrow/ContextMenu.d.ts.map +1 -1
  11. package/dist/ProfileFlameGraph/FlameGraphArrow/ContextMenu.js +13 -14
  12. package/dist/ProfileFlameGraph/FlameGraphArrow/TextWithEllipsis.d.ts.map +1 -1
  13. package/dist/ProfileFlameGraph/FlameGraphArrow/TextWithEllipsis.js +6 -5
  14. package/dist/ProfileFlameGraph/index.d.ts.map +1 -1
  15. package/dist/ProfileFlameGraph/index.js +8 -7
  16. package/dist/ProfileMetricsGraph/index.d.ts.map +1 -1
  17. package/dist/ProfileMetricsGraph/index.js +6 -8
  18. package/dist/ProfileSelector/MetricsGraphSection.d.ts.map +1 -1
  19. package/dist/ProfileSelector/MetricsGraphSection.js +48 -55
  20. package/dist/ProfileSelector/index.d.ts +1 -1
  21. package/dist/ProfileSelector/index.d.ts.map +1 -1
  22. package/dist/ProfileSelector/index.js +216 -210
  23. package/dist/ProfileSelector/useAutoQuerySelector.d.ts +1 -3
  24. package/dist/ProfileSelector/useAutoQuerySelector.d.ts.map +1 -1
  25. package/dist/ProfileSelector/useAutoQuerySelector.js +133 -104
  26. package/dist/ProfileView/components/ActionButtons/SortByDropdown.d.ts.map +1 -1
  27. package/dist/ProfileView/components/ActionButtons/SortByDropdown.js +24 -25
  28. package/dist/ProfileView/components/ColorStackLegend.d.ts.map +1 -1
  29. package/dist/ProfileView/components/ColorStackLegend.js +3 -5
  30. package/dist/ProfileView/components/InvertCallStack/index.d.ts.map +1 -1
  31. package/dist/ProfileView/components/InvertCallStack/index.js +47 -47
  32. package/dist/ProfileView/components/ProfileFilters/useProfileFiltersUrlState.d.ts +1 -2
  33. package/dist/ProfileView/components/ProfileFilters/useProfileFiltersUrlState.d.ts.map +1 -1
  34. package/dist/ProfileView/components/ProfileFilters/useProfileFiltersUrlState.js +37 -34
  35. package/dist/ProfileView/components/Toolbars/MultiLevelDropdown.d.ts.map +1 -1
  36. package/dist/ProfileView/components/Toolbars/MultiLevelDropdown.js +282 -294
  37. package/dist/ProfileView/components/Toolbars/TableColumnsDropdown.d.ts.map +1 -1
  38. package/dist/ProfileView/components/Toolbars/TableColumnsDropdown.js +7 -8
  39. package/dist/ProfileView/components/Toolbars/index.d.ts +2 -2
  40. package/dist/ProfileView/components/Toolbars/index.d.ts.map +1 -1
  41. package/dist/ProfileView/components/Toolbars/index.js +1 -1
  42. package/dist/ProfileView/components/ViewSelector/index.d.ts.map +1 -1
  43. package/dist/ProfileView/components/ViewSelector/index.js +53 -75
  44. package/dist/ProfileView/context/DashboardContext.d.ts.map +1 -1
  45. package/dist/ProfileView/context/DashboardContext.js +36 -44
  46. package/dist/ProfileView/hooks/useResetFlameGraphState.d.ts.map +1 -1
  47. package/dist/ProfileView/hooks/useResetFlameGraphState.js +8 -7
  48. package/dist/ProfileView/hooks/useResetStateOnProfileTypeChange.d.ts.map +1 -1
  49. package/dist/ProfileView/hooks/useResetStateOnProfileTypeChange.js +59 -59
  50. package/dist/ProfileView/hooks/useResetStateOnSeriesChange.d.ts.map +1 -1
  51. package/dist/ProfileView/hooks/useResetStateOnSeriesChange.js +37 -22
  52. package/dist/ProfileView/hooks/useVisualizationState.d.ts +3 -3
  53. package/dist/ProfileView/hooks/useVisualizationState.d.ts.map +1 -1
  54. package/dist/ProfileView/hooks/useVisualizationState.js +116 -147
  55. package/dist/ProfileViewWithData.d.ts.map +1 -1
  56. package/dist/ProfileViewWithData.js +35 -45
  57. package/dist/Sandwich/index.d.ts.map +1 -1
  58. package/dist/Sandwich/index.js +6 -5
  59. package/dist/SourceView/index.d.ts.map +1 -1
  60. package/dist/SourceView/index.js +6 -4
  61. package/dist/SourceView/useSelectedLineRange.d.ts.map +1 -1
  62. package/dist/SourceView/useSelectedLineRange.js +52 -76
  63. package/dist/Table/MoreDropdown.d.ts.map +1 -1
  64. package/dist/Table/MoreDropdown.js +42 -53
  65. package/dist/Table/TableContextMenu.d.ts.map +1 -1
  66. package/dist/Table/TableContextMenu.js +15 -19
  67. package/dist/Table/hooks/useTableConfiguration.d.ts.map +1 -1
  68. package/dist/Table/hooks/useTableConfiguration.js +107 -115
  69. package/dist/Table/index.d.ts.map +1 -1
  70. package/dist/Table/index.js +16 -16
  71. package/dist/TopTable/index.d.ts.map +1 -1
  72. package/dist/TopTable/index.js +112 -127
  73. package/dist/hooks/urlParsers.d.ts +18 -0
  74. package/dist/hooks/urlParsers.d.ts.map +1 -0
  75. package/dist/hooks/urlParsers.js +44 -0
  76. package/dist/hooks/useColorBy.d.ts +5 -0
  77. package/dist/hooks/useColorBy.d.ts.map +1 -0
  78. package/dist/hooks/useColorBy.js +63 -0
  79. package/dist/hooks/useCompareModeMeta.d.ts.map +1 -1
  80. package/dist/hooks/useCompareModeMeta.js +94 -138
  81. package/dist/hooks/useDashboardItems.d.ts +5 -0
  82. package/dist/hooks/useDashboardItems.d.ts.map +1 -0
  83. package/dist/hooks/useDashboardItems.js +68 -0
  84. package/dist/hooks/useQueryState.d.ts +4 -4
  85. package/dist/hooks/useQueryState.d.ts.map +1 -1
  86. package/dist/hooks/useQueryState.js +127 -122
  87. package/dist/index.d.ts +3 -2
  88. package/dist/index.d.ts.map +1 -1
  89. package/dist/index.js +3 -12
  90. package/dist/useSumBy.d.ts +1 -1
  91. package/dist/useSumBy.d.ts.map +1 -1
  92. package/dist/useSumBy.js +2 -2
  93. package/package.json +4 -3
  94. package/src/GraphTooltipArrow/useGraphTooltipMetaInfo/index.ts +11 -13
  95. package/src/ProfileExplorer/ProfileExplorerCompare.tsx +11 -9
  96. package/src/ProfileFlameChart/SamplesStrips/index.tsx +2 -2
  97. package/src/ProfileFlameChart/index.tsx +21 -28
  98. package/src/ProfileFlameGraph/FlameGraphArrow/ContextMenu.tsx +10 -9
  99. package/src/ProfileFlameGraph/FlameGraphArrow/TextWithEllipsis.tsx +5 -3
  100. package/src/ProfileFlameGraph/index.tsx +6 -9
  101. package/src/ProfileMetricsGraph/index.tsx +6 -8
  102. package/src/ProfileSelector/MetricsGraphSection.tsx +5 -10
  103. package/src/ProfileSelector/index.tsx +33 -33
  104. package/src/ProfileSelector/useAutoQuerySelector.ts +64 -42
  105. package/src/ProfileView/components/ActionButtons/SortByDropdown.tsx +10 -6
  106. package/src/ProfileView/components/ColorStackLegend.tsx +2 -4
  107. package/src/ProfileView/components/InvertCallStack/index.tsx +5 -4
  108. package/src/ProfileView/components/ProfileFilters/useProfileFiltersUrlState.test.tsx +94 -192
  109. package/src/ProfileView/components/ProfileFilters/useProfileFiltersUrlState.ts +21 -21
  110. package/src/ProfileView/components/Toolbars/MultiLevelDropdown.tsx +24 -25
  111. package/src/ProfileView/components/Toolbars/TableColumnsDropdown.tsx +4 -5
  112. package/src/ProfileView/components/Toolbars/index.tsx +3 -3
  113. package/src/ProfileView/components/ViewSelector/index.tsx +9 -16
  114. package/src/ProfileView/context/DashboardContext.tsx +6 -6
  115. package/src/ProfileView/hooks/useResetFlameGraphState.ts +6 -4
  116. package/src/ProfileView/hooks/useResetStateOnProfileTypeChange.ts +24 -26
  117. package/src/ProfileView/hooks/useResetStateOnSeriesChange.ts +16 -8
  118. package/src/ProfileView/hooks/useVisualizationState.ts +61 -69
  119. package/src/ProfileViewWithData.tsx +29 -35
  120. package/src/Sandwich/index.tsx +4 -3
  121. package/src/SourceView/index.tsx +4 -2
  122. package/src/SourceView/useSelectedLineRange.ts +34 -19
  123. package/src/Table/MoreDropdown.tsx +9 -11
  124. package/src/Table/TableContextMenu.tsx +10 -13
  125. package/src/Table/hooks/useTableConfiguration.tsx +3 -4
  126. package/src/Table/index.tsx +12 -21
  127. package/src/TopTable/index.tsx +3 -4
  128. package/src/hooks/urlParsers.ts +38 -0
  129. package/src/hooks/useColorBy.ts +42 -0
  130. package/src/hooks/useCompareModeMeta.ts +61 -91
  131. package/src/hooks/useDashboardItems.ts +46 -0
  132. package/src/hooks/useQueryState.test.tsx +275 -345
  133. package/src/hooks/useQueryState.ts +153 -120
  134. package/src/index.tsx +16 -15
  135. package/src/useSumBy.ts +3 -3
@@ -12,19 +12,20 @@
12
12
  // limitations under the License.
13
13
 
14
14
  import {Icon} from '@iconify/react';
15
+ import {useQueryState} from 'nuqs';
15
16
 
16
- import {Button, useURLState} from '@parca/components';
17
+ import {Button} from '@parca/components';
17
18
  import {TEST_IDS, testId} from '@parca/test-utils';
18
19
 
20
+ import {invertCallStackParser} from '../../../hooks/urlParsers';
19
21
  import {useResetFlameGraphState} from '../../hooks/useResetFlameGraphState';
20
22
 
21
23
  const InvertCallStack = (): JSX.Element => {
22
- const [invertStack = '', setInvertStack] = useURLState('invert_call_stack');
23
- const isInvert = invertStack === 'true';
24
+ const [isInvert, setInvertStack] = useQueryState('invert_call_stack', invertCallStackParser);
24
25
  const resetFlameGraphState = useResetFlameGraphState();
25
26
 
26
27
  const handleSetInvert = (value: boolean): void => {
27
- setInvertStack(value ? 'true' : '');
28
+ void setInvertStack(value);
28
29
 
29
30
  resetFlameGraphState();
30
31
  };
@@ -15,78 +15,28 @@ import {type ReactNode} from 'react';
15
15
 
16
16
  // eslint-disable-next-line import/named
17
17
  import {act, renderHook, waitFor} from '@testing-library/react';
18
- import {beforeEach, describe, expect, it, vi} from 'vitest';
19
-
20
- import {URLStateProvider} from '@parca/components';
18
+ // eslint-disable-next-line import/no-unresolved
19
+ import {NuqsTestingAdapter, type OnUrlUpdateFunction} from 'nuqs/adapters/testing';
20
+ import {describe, expect, it, vi} from 'vitest';
21
21
 
22
22
  import {type ProfileFilter} from './useProfileFilters';
23
23
  import {decodeProfileFilters, useProfileFiltersUrlState} from './useProfileFiltersUrlState';
24
24
 
25
- // Mock window.location
26
- const mockLocation = {
27
- pathname: '/test',
28
- search: '',
29
- };
30
-
31
- // Mock the navigate function
32
- const mockNavigateTo = vi.fn((path: string, params: Record<string, string | string[]>) => {
33
- const searchParams = new URLSearchParams();
34
- Object.entries(params).forEach(([key, value]) => {
35
- if (value !== undefined && value !== null) {
36
- if (Array.isArray(value)) {
37
- searchParams.set(key, value.join(','));
38
- } else {
39
- searchParams.set(key, String(value));
40
- }
41
- }
42
- });
43
- mockLocation.search = `?${searchParams.toString()}`;
44
- });
45
-
46
- // Mock getQueryParamsFromURL
47
- vi.mock('@parca/components/src/hooks/URLState/utils', async () => {
48
- const actual = await vi.importActual('@parca/components/src/hooks/URLState/utils');
49
- return {
50
- ...actual,
51
- getQueryParamsFromURL: () => {
52
- if (mockLocation.search === '') return {};
53
- const params = new URLSearchParams(mockLocation.search);
54
- const result: Record<string, string | string[]> = {};
55
- for (const [key, value] of params.entries()) {
56
- const decodedValue = decodeURIComponent(value);
57
- const existing = result[key];
58
- if (existing !== undefined) {
59
- result[key] = Array.isArray(existing)
60
- ? [...existing, decodedValue]
61
- : [existing, decodedValue];
62
- } else {
63
- result[key] = decodedValue;
64
- }
65
- }
66
- return result;
67
- },
68
- };
69
- });
70
-
71
- // Helper to create wrapper with URLStateProvider
72
- const createWrapper = (): (({children}: {children: ReactNode}) => JSX.Element) => {
25
+ // Helper to create wrapper with NuqsTestingAdapter
26
+ const createWrapper = (
27
+ searchParams: string | Record<string, string> = {},
28
+ onUrlUpdate?: OnUrlUpdateFunction
29
+ ): (({children}: {children: ReactNode}) => JSX.Element) => {
73
30
  const Wrapper = ({children}: {children: ReactNode}): JSX.Element => (
74
- <URLStateProvider navigateTo={mockNavigateTo}>{children}</URLStateProvider>
31
+ <NuqsTestingAdapter searchParams={searchParams} onUrlUpdate={onUrlUpdate} hasMemory={true}>
32
+ {children}
33
+ </NuqsTestingAdapter>
75
34
  );
76
- Wrapper.displayName = 'URLStateProviderWrapper';
35
+ Wrapper.displayName = 'NuqsTestingWrapper';
77
36
  return Wrapper;
78
37
  };
79
38
 
80
39
  describe('useProfileFiltersUrlState', () => {
81
- beforeEach(() => {
82
- mockNavigateTo.mockClear();
83
- Object.defineProperty(window, 'location', {
84
- value: mockLocation,
85
- writable: true,
86
- });
87
- mockLocation.search = '';
88
- });
89
-
90
40
  describe('decodeProfileFilters', () => {
91
41
  it('should return empty array for empty string', () => {
92
42
  expect(decodeProfileFilters('')).toEqual([]);
@@ -255,9 +205,9 @@ describe('useProfileFiltersUrlState', () => {
255
205
  });
256
206
 
257
207
  it('should read filters from URL', async () => {
258
- mockLocation.search = '?profile_filters=s:fn:=:testFunc';
259
-
260
- const {result} = renderHook(() => useProfileFiltersUrlState(), {wrapper: createWrapper()});
208
+ const {result} = renderHook(() => useProfileFiltersUrlState(), {
209
+ wrapper: createWrapper({profile_filters: 's:fn:=:testFunc'}),
210
+ });
261
211
 
262
212
  await waitFor(() => {
263
213
  expect(result.current.appliedFilters).toHaveLength(1);
@@ -271,7 +221,10 @@ describe('useProfileFiltersUrlState', () => {
271
221
  });
272
222
 
273
223
  it('should update URL when setting filters', async () => {
274
- const {result} = renderHook(() => useProfileFiltersUrlState(), {wrapper: createWrapper()});
224
+ const onUrlUpdate = vi.fn();
225
+ const {result} = renderHook(() => useProfileFiltersUrlState(), {
226
+ wrapper: createWrapper({}, onUrlUpdate),
227
+ });
275
228
 
276
229
  const newFilters: ProfileFilter[] = [
277
230
  {
@@ -288,26 +241,26 @@ describe('useProfileFiltersUrlState', () => {
288
241
  });
289
242
 
290
243
  await waitFor(() => {
291
- expect(mockNavigateTo).toHaveBeenCalled();
292
- const [, params] = mockNavigateTo.mock.calls[mockNavigateTo.mock.calls.length - 1];
293
- expect(params.profile_filters).toBe('f:b:!~:libc.so');
244
+ expect(onUrlUpdate).toHaveBeenCalled();
245
+ const lastCall = onUrlUpdate.mock.calls[onUrlUpdate.mock.calls.length - 1][0];
246
+ expect(lastCall.searchParams.get('profile_filters')).toBe('f:b:!~:libc.so');
294
247
  });
295
248
  });
296
249
 
297
250
  it('should clear URL param when setting empty filters', async () => {
298
- mockLocation.search = '?profile_filters=s:fn:=:testFunc';
299
-
300
- const {result} = renderHook(() => useProfileFiltersUrlState(), {wrapper: createWrapper()});
251
+ const onUrlUpdate = vi.fn();
252
+ const {result} = renderHook(() => useProfileFiltersUrlState(), {
253
+ wrapper: createWrapper({profile_filters: 's:fn:=:testFunc'}, onUrlUpdate),
254
+ });
301
255
 
302
256
  act(() => {
303
257
  result.current.setAppliedFilters([]);
304
258
  });
305
259
 
306
260
  await waitFor(() => {
307
- expect(mockNavigateTo).toHaveBeenCalled();
308
- const [, params] = mockNavigateTo.mock.calls[mockNavigateTo.mock.calls.length - 1];
309
- // When filters are empty, the param is either empty string or undefined (removed)
310
- expect(params.profile_filters === '' || params.profile_filters === undefined).toBe(true);
261
+ expect(onUrlUpdate).toHaveBeenCalled();
262
+ const lastCall = onUrlUpdate.mock.calls[onUrlUpdate.mock.calls.length - 1][0];
263
+ expect(lastCall.searchParams.has('profile_filters')).toBe(false);
311
264
  });
312
265
  });
313
266
  });
@@ -320,9 +273,10 @@ describe('useProfileFiltersUrlState', () => {
320
273
  });
321
274
 
322
275
  it('should force apply filters overwriting existing', async () => {
323
- mockLocation.search = '?profile_filters=s:fn:=:existingFunc';
324
-
325
- const {result} = renderHook(() => useProfileFiltersUrlState(), {wrapper: createWrapper()});
276
+ const onUrlUpdate = vi.fn();
277
+ const {result} = renderHook(() => useProfileFiltersUrlState(), {
278
+ wrapper: createWrapper({profile_filters: 's:fn:=:existingFunc'}, onUrlUpdate),
279
+ });
326
280
 
327
281
  // Verify existing filter is loaded
328
282
  await waitFor(() => {
@@ -344,33 +298,36 @@ describe('useProfileFiltersUrlState', () => {
344
298
  });
345
299
 
346
300
  await waitFor(() => {
347
- expect(mockNavigateTo).toHaveBeenCalled();
348
- const [, params] = mockNavigateTo.mock.calls[mockNavigateTo.mock.calls.length - 1];
349
- expect(params.profile_filters).toBe('f:b:!~:forcedValue');
301
+ expect(onUrlUpdate).toHaveBeenCalled();
302
+ const lastCall = onUrlUpdate.mock.calls[onUrlUpdate.mock.calls.length - 1][0];
303
+ expect(lastCall.searchParams.get('profile_filters')).toBe('f:b:!~:forcedValue');
350
304
  });
351
305
  });
352
306
 
353
307
  it('should clear filters when force applying empty array', async () => {
354
- mockLocation.search = '?profile_filters=s:fn:=:existingFunc';
355
-
356
- const {result} = renderHook(() => useProfileFiltersUrlState(), {wrapper: createWrapper()});
308
+ const onUrlUpdate = vi.fn();
309
+ const {result} = renderHook(() => useProfileFiltersUrlState(), {
310
+ wrapper: createWrapper({profile_filters: 's:fn:=:existingFunc'}, onUrlUpdate),
311
+ });
357
312
 
358
313
  act(() => {
359
314
  result.current.forceApplyFilters([]);
360
315
  });
361
316
 
362
317
  await waitFor(() => {
363
- expect(mockNavigateTo).toHaveBeenCalled();
364
- const [, params] = mockNavigateTo.mock.calls[mockNavigateTo.mock.calls.length - 1];
365
- // When filters are empty, the param is either empty string or undefined (removed)
366
- expect(params.profile_filters === '' || params.profile_filters === undefined).toBe(true);
318
+ expect(onUrlUpdate).toHaveBeenCalled();
319
+ const lastCall = onUrlUpdate.mock.calls[onUrlUpdate.mock.calls.length - 1][0];
320
+ expect(lastCall.searchParams.has('profile_filters')).toBe(false);
367
321
  });
368
322
  });
369
323
  });
370
324
 
371
325
  describe('Preset filter encoding', () => {
372
326
  it('should encode preset filters correctly', async () => {
373
- const {result} = renderHook(() => useProfileFiltersUrlState(), {wrapper: createWrapper()});
327
+ const onUrlUpdate = vi.fn();
328
+ const {result} = renderHook(() => useProfileFiltersUrlState(), {
329
+ wrapper: createWrapper({}, onUrlUpdate),
330
+ });
374
331
 
375
332
  const presetFilters: ProfileFilter[] = [
376
333
  {
@@ -385,14 +342,17 @@ describe('useProfileFiltersUrlState', () => {
385
342
  });
386
343
 
387
344
  await waitFor(() => {
388
- expect(mockNavigateTo).toHaveBeenCalled();
389
- const [, params] = mockNavigateTo.mock.calls[mockNavigateTo.mock.calls.length - 1];
390
- expect(params.profile_filters).toBe('p:hide_libc:enabled');
345
+ expect(onUrlUpdate).toHaveBeenCalled();
346
+ const lastCall = onUrlUpdate.mock.calls[onUrlUpdate.mock.calls.length - 1][0];
347
+ expect(lastCall.searchParams.get('profile_filters')).toBe('p:hide_libc:enabled');
391
348
  });
392
349
  });
393
350
 
394
351
  it('should handle mixed preset and regular filters', async () => {
395
- const {result} = renderHook(() => useProfileFiltersUrlState(), {wrapper: createWrapper()});
352
+ const onUrlUpdate = vi.fn();
353
+ const {result} = renderHook(() => useProfileFiltersUrlState(), {
354
+ wrapper: createWrapper({}, onUrlUpdate),
355
+ });
396
356
 
397
357
  const mixedFilters: ProfileFilter[] = [
398
358
  {
@@ -414,16 +374,21 @@ describe('useProfileFiltersUrlState', () => {
414
374
  });
415
375
 
416
376
  await waitFor(() => {
417
- expect(mockNavigateTo).toHaveBeenCalled();
418
- const [, params] = mockNavigateTo.mock.calls[mockNavigateTo.mock.calls.length - 1];
419
- expect(params.profile_filters).toBe('p:hide_libc:enabled,f:b:!~:node');
377
+ expect(onUrlUpdate).toHaveBeenCalled();
378
+ const lastCall = onUrlUpdate.mock.calls[onUrlUpdate.mock.calls.length - 1][0];
379
+ expect(lastCall.searchParams.get('profile_filters')).toBe(
380
+ 'p:hide_libc:enabled,f:b:!~:node'
381
+ );
420
382
  });
421
383
  });
422
384
  });
423
385
 
424
386
  describe('URL encoding edge cases', () => {
425
387
  it('should handle special characters in filter values', async () => {
426
- const {result} = renderHook(() => useProfileFiltersUrlState(), {wrapper: createWrapper()});
388
+ const onUrlUpdate = vi.fn();
389
+ const {result} = renderHook(() => useProfileFiltersUrlState(), {
390
+ wrapper: createWrapper({}, onUrlUpdate),
391
+ });
427
392
 
428
393
  const filtersWithSpecialChars: ProfileFilter[] = [
429
394
  {
@@ -440,15 +405,19 @@ describe('useProfileFiltersUrlState', () => {
440
405
  });
441
406
 
442
407
  await waitFor(() => {
443
- expect(mockNavigateTo).toHaveBeenCalled();
444
- const [, params] = mockNavigateTo.mock.calls[mockNavigateTo.mock.calls.length - 1];
445
- // Value should be URL encoded
446
- expect(params.profile_filters).toContain('std%3A%3Avector%3Cint%3E');
408
+ expect(onUrlUpdate).toHaveBeenCalled();
409
+ const lastCall = onUrlUpdate.mock.calls[onUrlUpdate.mock.calls.length - 1][0];
410
+ const filterValue = lastCall.searchParams.get('profile_filters');
411
+ // The value should contain the encoded special characters
412
+ expect(filterValue).toContain('std%3A%3Avector%3Cint%3E');
447
413
  });
448
414
  });
449
415
 
450
416
  it('should filter out incomplete filters when encoding', async () => {
451
- const {result} = renderHook(() => useProfileFiltersUrlState(), {wrapper: createWrapper()});
417
+ const onUrlUpdate = vi.fn();
418
+ const {result} = renderHook(() => useProfileFiltersUrlState(), {
419
+ wrapper: createWrapper({}, onUrlUpdate),
420
+ });
452
421
 
453
422
  const incompleteFilters: ProfileFilter[] = [
454
423
  {
@@ -476,10 +445,10 @@ describe('useProfileFiltersUrlState', () => {
476
445
  });
477
446
 
478
447
  await waitFor(() => {
479
- expect(mockNavigateTo).toHaveBeenCalled();
480
- const [, params] = mockNavigateTo.mock.calls[mockNavigateTo.mock.calls.length - 1];
448
+ expect(onUrlUpdate).toHaveBeenCalled();
449
+ const lastCall = onUrlUpdate.mock.calls[onUrlUpdate.mock.calls.length - 1][0];
481
450
  // Only the complete filter should be encoded
482
- expect(params.profile_filters).toBe('f:b:!~:valid');
451
+ expect(lastCall.searchParams.get('profile_filters')).toBe('f:b:!~:valid');
483
452
  });
484
453
  });
485
454
  });
@@ -501,9 +470,9 @@ describe('useProfileFiltersUrlState', () => {
501
470
  });
502
471
 
503
472
  it('should return correctly structured filters from URL', async () => {
504
- mockLocation.search = '?profile_filters=s:fn:=:testFunc';
505
-
506
- const {result} = renderHook(() => useProfileFiltersUrlState(), {wrapper: createWrapper()});
473
+ const {result} = renderHook(() => useProfileFiltersUrlState(), {
474
+ wrapper: createWrapper({profile_filters: 's:fn:=:testFunc'}),
475
+ });
507
476
 
508
477
  await waitFor(() => {
509
478
  expect(result.current.appliedFilters).toHaveLength(1);
@@ -522,15 +491,16 @@ describe('useProfileFiltersUrlState', () => {
522
491
 
523
492
  describe('View switching scenarios', () => {
524
493
  it('should completely replace filters when switching views using forceApplyFilters', async () => {
525
- // Start with View A's filters (2 filters)
526
- mockLocation.search = '?profile_filters=s:fn:=:viewAFunc,f:b:!=:viewABinary';
527
-
528
- const {result} = renderHook(() => useProfileFiltersUrlState(), {wrapper: createWrapper()});
494
+ const onUrlUpdate = vi.fn();
495
+ const {result} = renderHook(() => useProfileFiltersUrlState(), {
496
+ wrapper: createWrapper(
497
+ {profile_filters: 's:fn:=:viewAFunc,f:b:!=:viewABinary'},
498
+ onUrlUpdate
499
+ ),
500
+ });
529
501
 
530
502
  await waitFor(() => {
531
503
  expect(result.current.appliedFilters).toHaveLength(2);
532
- expect(result.current.appliedFilters[0].value).toBe('viewAFunc');
533
- expect(result.current.appliedFilters[1].value).toBe('viewABinary');
534
504
  });
535
505
 
536
506
  // Switch to View B (completely different filter)
@@ -549,96 +519,28 @@ describe('useProfileFiltersUrlState', () => {
549
519
  });
550
520
 
551
521
  await waitFor(() => {
552
- expect(mockNavigateTo).toHaveBeenCalled();
553
- const [, params] = mockNavigateTo.mock.calls[mockNavigateTo.mock.calls.length - 1];
522
+ expect(onUrlUpdate).toHaveBeenCalled();
523
+ const lastCall = onUrlUpdate.mock.calls[onUrlUpdate.mock.calls.length - 1][0];
524
+ const filterValue = lastCall.searchParams.get('profile_filters');
554
525
 
555
526
  // View A's filters should be completely gone
556
- expect(params.profile_filters).not.toContain('viewAFunc');
557
- expect(params.profile_filters).not.toContain('viewABinary');
527
+ expect(filterValue).not.toContain('viewAFunc');
528
+ expect(filterValue).not.toContain('viewABinary');
558
529
 
559
530
  // Only View B's filter should be present
560
- expect(params.profile_filters).toBe('f:fn:~:viewBOnly');
561
- });
562
- });
563
-
564
- it('should handle sequential view switches correctly', async () => {
565
- // Simulate: [default] -> [storage] -> [testing-view]
566
- const {result} = renderHook(() => useProfileFiltersUrlState(), {wrapper: createWrapper()});
567
-
568
- // View 1: default view (1 filter)
569
- const defaultFilters: ProfileFilter[] = [{id: 'd-1', type: 'hide_libc', value: 'enabled'}];
570
-
571
- act(() => {
572
- result.current.forceApplyFilters(defaultFilters);
573
- });
574
-
575
- await waitFor(() => {
576
- expect(mockNavigateTo).toHaveBeenCalled();
577
- const [, params] = mockNavigateTo.mock.calls[mockNavigateTo.mock.calls.length - 1];
578
- expect(params.profile_filters).toBe('p:hide_libc:enabled');
579
- });
580
-
581
- mockNavigateTo.mockClear();
582
-
583
- // View 2: storage view (3 filters)
584
- const storageFilters: ProfileFilter[] = [
585
- {id: 's-1', type: 'stack', field: 'function_name', matchType: 'not_contains', value: 'io'},
586
- {id: 's-2', type: 'frame', field: 'binary', matchType: 'not_contains', value: 'disk'},
587
- {id: 's-3', type: 'frame', field: 'function_name', matchType: 'contains', value: 'storage'},
588
- ];
589
-
590
- act(() => {
591
- result.current.forceApplyFilters(storageFilters);
592
- });
593
-
594
- await waitFor(() => {
595
- expect(mockNavigateTo).toHaveBeenCalled();
596
- const [, params] = mockNavigateTo.mock.calls[mockNavigateTo.mock.calls.length - 1];
597
- // Default view's filter should be gone
598
- expect(params.profile_filters).not.toContain('hide_libc');
599
- // Storage view should have 3 filters
600
- expect(params.profile_filters).toContain('io');
601
- expect(params.profile_filters).toContain('disk');
602
- expect(params.profile_filters).toContain('storage');
603
- });
604
-
605
- mockNavigateTo.mockClear();
606
-
607
- // View 3: testing-view (2 filters)
608
- const testingFilters: ProfileFilter[] = [
609
- {id: 't-1', type: 'stack', field: 'function_name', matchType: 'equal', value: 'test_main'},
610
- {id: 't-2', type: 'frame', field: 'binary', matchType: 'contains', value: 'test'},
611
- ];
612
-
613
- act(() => {
614
- result.current.forceApplyFilters(testingFilters);
615
- });
616
-
617
- await waitFor(() => {
618
- expect(mockNavigateTo).toHaveBeenCalled();
619
- const [, params] = mockNavigateTo.mock.calls[mockNavigateTo.mock.calls.length - 1];
620
- // Storage view's filters should be gone
621
- expect(params.profile_filters).not.toContain('io');
622
- expect(params.profile_filters).not.toContain('disk');
623
- expect(params.profile_filters).not.toContain('storage');
624
- // Testing view should have its 2 filters
625
- expect(params.profile_filters).toContain('test_main');
626
- expect(params.profile_filters).toContain('test');
531
+ expect(filterValue).toBe('f:fn:~:viewBOnly');
627
532
  });
628
533
  });
629
534
 
630
535
  it('should not change filters when clicking the same view tab', async () => {
631
- // Start with existing filters
632
- mockLocation.search = '?profile_filters=s:fn:=:existingFilter';
633
-
634
- const {result} = renderHook(() => useProfileFiltersUrlState(), {wrapper: createWrapper()});
536
+ const {result} = renderHook(() => useProfileFiltersUrlState(), {
537
+ wrapper: createWrapper({profile_filters: 's:fn:=:existingFilter'}),
538
+ });
635
539
 
636
540
  await waitFor(() => {
637
541
  expect(result.current.appliedFilters).toHaveLength(1);
638
542
  });
639
543
 
640
- mockNavigateTo.mockClear();
641
-
642
544
  // Apply the same filters (simulating clicking the same view tab)
643
545
  const sameFilters: ProfileFilter[] = [
644
546
  {
@@ -13,7 +13,8 @@
13
13
 
14
14
  import {useCallback, useMemo} from 'react';
15
15
 
16
- import {useURLStateBatch, useURLStateCustom, type ParamValueSetterCustom} from '@parca/components';
16
+ import {createParser, useQueryState} from 'nuqs';
17
+
17
18
  import {safeDecode} from '@parca/utilities';
18
19
 
19
20
  import {isPresetKey} from './filterPresets';
@@ -137,31 +138,32 @@ export const decodeProfileFilters = (encoded: string): ProfileFilter[] => {
137
138
  }
138
139
  };
139
140
 
141
+ const profileFiltersParser = createParser<ProfileFilter[]>({
142
+ parse: (value: string) => decodeProfileFilters(value),
143
+ serialize: (value: ProfileFilter[]) => encodeProfileFilters(value),
144
+ eq: (a, b) => encodeProfileFilters(a) === encodeProfileFilters(b),
145
+ })
146
+ .withDefault([])
147
+ .withOptions({history: 'replace'});
148
+
140
149
  export const useProfileFiltersUrlState = (): {
141
150
  appliedFilters: ProfileFilter[];
142
- setAppliedFilters: ParamValueSetterCustom<ProfileFilter[]>;
151
+ setAppliedFilters: (filters: ProfileFilter[]) => void;
143
152
  forceApplyFilters: (filters: ProfileFilter[]) => void;
144
153
  } => {
145
- const batchUpdates = useURLStateBatch();
146
-
147
- // Store applied filters in URL state for persistence using compact encoding
148
- const [appliedFilters, setAppliedFilters] = useURLStateCustom<ProfileFilter[]>(
149
- `profile_filters`,
150
- {
151
- parse: value => {
152
- return decodeProfileFilters(value as string);
153
- },
154
- stringify: value => {
155
- return encodeProfileFilters(value);
156
- },
157
- defaultValue: [],
158
- }
159
- );
154
+ const [appliedFilters, setRawFilters] = useQueryState('profile_filters', profileFiltersParser);
160
155
 
161
156
  const memoizedAppliedFilters = useMemo(() => {
162
157
  return appliedFilters ?? [];
163
158
  }, [appliedFilters]);
164
159
 
160
+ const setAppliedFilters = useCallback(
161
+ (filters: ProfileFilter[]) => {
162
+ void setRawFilters(filters);
163
+ },
164
+ [setRawFilters]
165
+ );
166
+
165
167
  // Force apply filters (bypasses preserve-existing strategy)
166
168
  const forceApplyFilters = useCallback(
167
169
  (filters: ProfileFilter[]) => {
@@ -172,11 +174,9 @@ export const useProfileFiltersUrlState = (): {
172
174
  return f.value !== '' && f.type != null && f.field != null && f.matchType != null;
173
175
  });
174
176
 
175
- batchUpdates(() => {
176
- setAppliedFilters(validFilters);
177
- });
177
+ setAppliedFilters(validFilters);
178
178
  },
179
- [batchUpdates, setAppliedFilters]
179
+ [setAppliedFilters]
180
180
  );
181
181
 
182
182
  return {
@@ -18,8 +18,8 @@ import React, {useCallback, useEffect, useRef, useState} from 'react';
18
18
  import {Menu} from '@headlessui/react';
19
19
  import {Icon} from '@iconify/react';
20
20
  import cx from 'classnames';
21
+ import {useQueryState} from 'nuqs';
21
22
 
22
- import {useURLState} from '@parca/components';
23
23
  import {USER_PREFERENCES, useUserPreference} from '@parca/hooks';
24
24
  import {ProfileType} from '@parca/parser';
25
25
 
@@ -29,6 +29,7 @@ import {
29
29
  FIELD_LOCATION_ADDRESS,
30
30
  FIELD_MAPPING_FILE,
31
31
  } from '../../../ProfileFlameGraph/FlameGraphArrow';
32
+ import {boolParam, hiddenBinariesParser, stringParam} from '../../../hooks/urlParsers';
32
33
  import {useProfileViewContext} from '../../context/ProfileViewContext';
33
34
  import SwitchMenuItem from './SwitchMenuItem';
34
35
 
@@ -209,14 +210,15 @@ const MultiLevelDropdown: React.FC<MultiLevelDropdownProps> = ({
209
210
  }) => {
210
211
  const dropdownRef = useRef<HTMLDivElement>(null);
211
212
  const [shouldOpenLeft, setShouldOpenLeft] = useState(false);
212
- const [storeSortBy] = useURLState('sort_by', {
213
- defaultValue: FIELD_FUNCTION_NAME,
214
- });
215
- const [colorStackLegend, setStoreColorStackLegend] = useURLState('color_stack_legend');
216
- const [hiddenBinaries, setHiddenBinaries] = useURLState('hidden_binaries', {
217
- defaultValue: [],
218
- alwaysReturnArray: true,
219
- });
213
+ const [storeSortBy] = useQueryState('sort_by', stringParam.withDefault(FIELD_FUNCTION_NAME));
214
+ const [colorStackLegend, setStoreColorStackLegend] = useQueryState(
215
+ 'color_stack_legend',
216
+ stringParam
217
+ );
218
+ const [hiddenBinaries, setHiddenBinaries] = useQueryState(
219
+ 'hidden_binaries',
220
+ hiddenBinariesParser
221
+ );
220
222
  const {compareMode} = useProfileViewContext();
221
223
  const [colorProfileName] = useUserPreference<string>(
222
224
  USER_PREFERENCES.FLAMEGRAPH_COLOR_PROFILE.key
@@ -226,11 +228,10 @@ const MultiLevelDropdown: React.FC<MultiLevelDropdownProps> = ({
226
228
 
227
229
  // By default, we want delta profiles (CPU) to be relatively compared.
228
230
  // For non-delta profiles, like goroutines or memory, we want the profiles to be compared absolutely.
229
- const compareAbsoluteDefault = profileType?.delta === false ? 'true' : 'false';
231
+ const compareAbsoluteDefault = profileType?.delta === false;
230
232
 
231
- const [compareAbsolute = compareAbsoluteDefault, setCompareAbsolute] =
232
- useURLState('compare_absolute');
233
- const isCompareAbsolute = compareAbsolute === 'true';
233
+ const [compareAbsolute, setCompareAbsolute] = useQueryState('compare_absolute', boolParam);
234
+ const isCompareAbsolute = compareAbsolute ?? compareAbsoluteDefault;
234
235
 
235
236
  useEffect(() => {
236
237
  const checkOverflow = (): void => {
@@ -251,20 +252,20 @@ const MultiLevelDropdown: React.FC<MultiLevelDropdownProps> = ({
251
252
  }, [isTableVizOnly]);
252
253
 
253
254
  const handleBinaryToggle = (index: number): void => {
254
- const updatedBinaries = [...(hiddenBinaries as string[])];
255
+ const updatedBinaries = [...hiddenBinaries];
255
256
  updatedBinaries.splice(index, 1);
256
- setHiddenBinaries(updatedBinaries);
257
+ void setHiddenBinaries(updatedBinaries);
257
258
  };
258
259
 
259
260
  const setColorStackLegend = useCallback(
260
261
  (value: string): void => {
261
- setStoreColorStackLegend(value);
262
+ void setStoreColorStackLegend(value);
262
263
  },
263
264
  [setStoreColorStackLegend]
264
265
  );
265
266
 
266
267
  const resetLegend = (): void => {
267
- setHiddenBinaries([]);
268
+ void setHiddenBinaries([]);
268
269
  };
269
270
 
270
271
  const menuItems: MenuItemType[] = [
@@ -332,7 +333,7 @@ const MultiLevelDropdown: React.FC<MultiLevelDropdownProps> = ({
332
333
  },
333
334
  {
334
335
  label: isCompareAbsolute ? 'Compare Relative' : 'Compare Absolute',
335
- onclick: () => setCompareAbsolute(isCompareAbsolute ? 'false' : 'true'),
336
+ onclick: () => void setCompareAbsolute(!isCompareAbsolute),
336
337
  hide: !compareMode,
337
338
  icon: isCompareAbsolute ? 'fluent-mdl2:compare' : 'fluent-mdl2:compare-uneven',
338
339
  },
@@ -362,7 +363,7 @@ const MultiLevelDropdown: React.FC<MultiLevelDropdownProps> = ({
362
363
  },
363
364
  {
364
365
  label: 'Reset Legend',
365
- hide: hiddenBinaries === undefined || hiddenBinaries.length === 0,
366
+ hide: hiddenBinaries.length === 0,
366
367
  onclick: () => resetLegend(),
367
368
  id: 'h-reset-legend-button',
368
369
  icon: 'system-uicons:reset',
@@ -370,7 +371,7 @@ const MultiLevelDropdown: React.FC<MultiLevelDropdownProps> = ({
370
371
  {
371
372
  label: 'Hidden Binaries',
372
373
  id: 'h-hidden-binaries',
373
- items: (hiddenBinaries as string[])?.map((binary, index) => ({
374
+ items: hiddenBinaries.map((binary, index) => ({
374
375
  label: binary,
375
376
  customSubmenu: (
376
377
  <div className="flex items-center gap-2 w-full">
@@ -386,7 +387,7 @@ const MultiLevelDropdown: React.FC<MultiLevelDropdownProps> = ({
386
387
  </div>
387
388
  ),
388
389
  })),
389
- hide: hiddenBinaries === undefined || hiddenBinaries.length === 0,
390
+ hide: hiddenBinaries.length === 0,
390
391
  icon: 'ph:eye-closed',
391
392
  },
392
393
  ];
@@ -427,10 +428,8 @@ const MultiLevelDropdown: React.FC<MultiLevelDropdownProps> = ({
427
428
  {...item}
428
429
  onSelect={onSelect}
429
430
  closeDropdown={close}
430
- activeValueForSortBy={storeSortBy as string}
431
- activeValueForColorBy={
432
- colorBy === undefined || colorBy === '' ? 'binary' : colorBy
433
- }
431
+ activeValueForSortBy={storeSortBy}
432
+ activeValueForColorBy={colorBy}
434
433
  activeValuesForLevel={groupBy}
435
434
  renderAsDiv={item.renderAsDiv}
436
435
  />