@parca/profile 0.19.138 → 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 (138) hide show
  1. package/CHANGELOG.md +10 -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/ProfileSelector/useAutoQuerySelector.d.ts.map +1 -1
  23. package/dist/ProfileSelector/useAutoQuerySelector.js +3 -0
  24. package/dist/ProfileTypeSelector/index.d.ts.map +1 -1
  25. package/dist/ProfileTypeSelector/index.js +4 -0
  26. package/dist/ProfileView/components/ActionButtons/SortByDropdown.d.ts.map +1 -1
  27. package/dist/ProfileView/components/ActionButtons/SortByDropdown.js +5 -5
  28. package/dist/ProfileView/components/ColorStackLegend.d.ts.map +1 -1
  29. package/dist/ProfileView/components/ColorStackLegend.js +2 -3
  30. package/dist/ProfileView/components/InvertCallStack/index.d.ts.map +1 -1
  31. package/dist/ProfileView/components/InvertCallStack/index.js +5 -4
  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 +14 -16
  35. package/dist/ProfileView/components/ProfileFilters/useProfileFiltersUrlState.test.js +84 -170
  36. package/dist/ProfileView/components/Toolbars/MultiLevelDropdown.d.ts.map +1 -1
  37. package/dist/ProfileView/components/Toolbars/MultiLevelDropdown.js +16 -20
  38. package/dist/ProfileView/components/Toolbars/TableColumnsDropdown.d.ts.map +1 -1
  39. package/dist/ProfileView/components/Toolbars/TableColumnsDropdown.js +4 -5
  40. package/dist/ProfileView/components/Toolbars/index.d.ts +2 -2
  41. package/dist/ProfileView/components/Toolbars/index.d.ts.map +1 -1
  42. package/dist/ProfileView/components/Toolbars/index.js +1 -1
  43. package/dist/ProfileView/components/ViewSelector/index.d.ts.map +1 -1
  44. package/dist/ProfileView/components/ViewSelector/index.js +8 -14
  45. package/dist/ProfileView/context/DashboardContext.d.ts.map +1 -1
  46. package/dist/ProfileView/context/DashboardContext.js +6 -6
  47. package/dist/ProfileView/hooks/useResetFlameGraphState.d.ts.map +1 -1
  48. package/dist/ProfileView/hooks/useResetFlameGraphState.js +5 -4
  49. package/dist/ProfileView/hooks/useResetStateOnProfileTypeChange.d.ts.map +1 -1
  50. package/dist/ProfileView/hooks/useResetStateOnProfileTypeChange.js +25 -26
  51. package/dist/ProfileView/hooks/useResetStateOnSeriesChange.d.ts.map +1 -1
  52. package/dist/ProfileView/hooks/useResetStateOnSeriesChange.js +13 -8
  53. package/dist/ProfileView/hooks/useVisualizationState.d.ts +3 -3
  54. package/dist/ProfileView/hooks/useVisualizationState.d.ts.map +1 -1
  55. package/dist/ProfileView/hooks/useVisualizationState.js +35 -51
  56. package/dist/ProfileViewWithData.d.ts.map +1 -1
  57. package/dist/ProfileViewWithData.js +19 -28
  58. package/dist/Sandwich/index.d.ts.map +1 -1
  59. package/dist/Sandwich/index.js +4 -3
  60. package/dist/SourceView/index.d.ts.map +1 -1
  61. package/dist/SourceView/index.js +4 -2
  62. package/dist/SourceView/useSelectedLineRange.d.ts.map +1 -1
  63. package/dist/SourceView/useSelectedLineRange.js +21 -16
  64. package/dist/Table/MoreDropdown.d.ts.map +1 -1
  65. package/dist/Table/MoreDropdown.js +8 -11
  66. package/dist/Table/TableContextMenu.d.ts.map +1 -1
  67. package/dist/Table/TableContextMenu.js +10 -13
  68. package/dist/Table/hooks/useTableConfiguration.d.ts.map +1 -1
  69. package/dist/Table/hooks/useTableConfiguration.js +3 -4
  70. package/dist/Table/index.d.ts.map +1 -1
  71. package/dist/Table/index.js +11 -9
  72. package/dist/TopTable/index.d.ts.map +1 -1
  73. package/dist/TopTable/index.js +3 -4
  74. package/dist/hooks/urlParsers.d.ts +18 -0
  75. package/dist/hooks/urlParsers.d.ts.map +1 -0
  76. package/dist/hooks/urlParsers.js +32 -0
  77. package/dist/hooks/useColorBy.d.ts +5 -0
  78. package/dist/hooks/useColorBy.d.ts.map +1 -0
  79. package/dist/hooks/useColorBy.js +26 -0
  80. package/dist/hooks/useCompareModeMeta.d.ts.map +1 -1
  81. package/dist/hooks/useCompareModeMeta.js +55 -86
  82. package/dist/hooks/useDashboardItems.d.ts +5 -0
  83. package/dist/hooks/useDashboardItems.d.ts.map +1 -0
  84. package/dist/hooks/useDashboardItems.js +27 -0
  85. package/dist/hooks/useQueryState.d.ts +3 -3
  86. package/dist/hooks/useQueryState.d.ts.map +1 -1
  87. package/dist/hooks/useQueryState.js +105 -105
  88. package/dist/hooks/useQueryState.test.js +186 -302
  89. package/dist/index.d.ts +3 -2
  90. package/dist/index.d.ts.map +1 -1
  91. package/dist/index.js +3 -12
  92. package/dist/useSumBy.d.ts +1 -1
  93. package/dist/useSumBy.d.ts.map +1 -1
  94. package/dist/useSumBy.js +2 -2
  95. package/package.json +12 -11
  96. package/src/GraphTooltipArrow/useGraphTooltipMetaInfo/index.ts +11 -13
  97. package/src/ProfileExplorer/ProfileExplorerCompare.tsx +4 -9
  98. package/src/ProfileFlameChart/SamplesStrips/index.tsx +2 -2
  99. package/src/ProfileFlameChart/index.tsx +21 -28
  100. package/src/ProfileFlameGraph/FlameGraphArrow/ContextMenu.tsx +10 -9
  101. package/src/ProfileFlameGraph/FlameGraphArrow/TextWithEllipsis.tsx +5 -3
  102. package/src/ProfileFlameGraph/index.tsx +6 -9
  103. package/src/ProfileMetricsGraph/index.tsx +6 -8
  104. package/src/ProfileSelector/MetricsGraphSection.tsx +5 -10
  105. package/src/ProfileSelector/index.tsx +32 -31
  106. package/src/ProfileSelector/useAutoQuerySelector.ts +5 -0
  107. package/src/ProfileTypeSelector/index.tsx +4 -0
  108. package/src/ProfileView/components/ActionButtons/SortByDropdown.tsx +10 -6
  109. package/src/ProfileView/components/ColorStackLegend.tsx +2 -4
  110. package/src/ProfileView/components/InvertCallStack/index.tsx +5 -4
  111. package/src/ProfileView/components/ProfileFilters/useProfileFiltersUrlState.test.tsx +94 -192
  112. package/src/ProfileView/components/ProfileFilters/useProfileFiltersUrlState.ts +21 -21
  113. package/src/ProfileView/components/Toolbars/MultiLevelDropdown.tsx +24 -25
  114. package/src/ProfileView/components/Toolbars/TableColumnsDropdown.tsx +4 -5
  115. package/src/ProfileView/components/Toolbars/index.tsx +3 -3
  116. package/src/ProfileView/components/ViewSelector/index.tsx +9 -16
  117. package/src/ProfileView/context/DashboardContext.tsx +6 -6
  118. package/src/ProfileView/hooks/useResetFlameGraphState.ts +6 -4
  119. package/src/ProfileView/hooks/useResetStateOnProfileTypeChange.ts +24 -26
  120. package/src/ProfileView/hooks/useResetStateOnSeriesChange.ts +16 -8
  121. package/src/ProfileView/hooks/useVisualizationState.ts +61 -69
  122. package/src/ProfileViewWithData.tsx +29 -35
  123. package/src/Sandwich/index.tsx +4 -3
  124. package/src/SourceView/index.tsx +4 -2
  125. package/src/SourceView/useSelectedLineRange.ts +34 -19
  126. package/src/Table/MoreDropdown.tsx +9 -11
  127. package/src/Table/TableContextMenu.tsx +10 -13
  128. package/src/Table/hooks/useTableConfiguration.tsx +3 -4
  129. package/src/Table/index.tsx +12 -21
  130. package/src/TopTable/index.tsx +3 -4
  131. package/src/hooks/urlParsers.ts +38 -0
  132. package/src/hooks/useColorBy.ts +42 -0
  133. package/src/hooks/useCompareModeMeta.ts +61 -91
  134. package/src/hooks/useDashboardItems.ts +46 -0
  135. package/src/hooks/useQueryState.test.tsx +275 -345
  136. package/src/hooks/useQueryState.ts +136 -118
  137. package/src/index.tsx +16 -15
  138. package/src/useSumBy.ts +3 -3
@@ -14,16 +14,10 @@
14
14
  import {Dispatch, SetStateAction, useCallback, useEffect, useMemo, useRef, useState} from 'react';
15
15
 
16
16
  import {RpcError} from '@protobuf-ts/runtime-rpc';
17
+ import {useQueryState as useNuqsQueryState} from 'nuqs';
17
18
 
18
19
  import {ProfileTypesRequest, ProfileTypesResponse, QueryServiceClient} from '@parca/client';
19
- import {
20
- DateTimeRange,
21
- IconButton,
22
- useGrpcMetadata,
23
- useParcaContext,
24
- useURLState,
25
- useURLStateBatch,
26
- } from '@parca/components';
20
+ import {DateTimeRange, IconButton, useGrpcMetadata, useParcaContext} from '@parca/components';
27
21
  import {CloseIcon} from '@parca/icons';
28
22
  import {Query} from '@parca/parser';
29
23
  import {TEST_IDS, testId} from '@parca/test-utils';
@@ -36,6 +30,7 @@ import {
36
30
  import {QueryControls} from '../QueryControls';
37
31
  import {LabelsQueryProvider, useLabelsQueryProvider} from '../contexts/LabelsQueryProvider';
38
32
  import {UnifiedLabelsProvider} from '../contexts/UnifiedLabelsContext';
33
+ import {stringParam} from '../hooks/urlParsers';
39
34
  import {useLabelNames} from '../hooks/useLabels';
40
35
  import {useQueryState} from '../hooks/useQueryState';
41
36
  import useGrpcQuery from '../useGrpcQuery';
@@ -118,8 +113,16 @@ const ProfileSelector = ({
118
113
  onSearchHook,
119
114
  }: ProfileSelectorProps): JSX.Element => {
120
115
  const {externalProfilerComponent, additionalMetricsGraph} = useParcaContext();
121
- const [queryBrowserMode, setQueryBrowserMode] = useURLState('query_browser_mode');
122
- const batchUpdates = useURLStateBatch();
116
+ const [queryBrowserMode, setRawQueryBrowserMode] = useNuqsQueryState(
117
+ 'query_browser_mode',
118
+ stringParam
119
+ );
120
+ const setQueryBrowserMode = useCallback(
121
+ (mode: string | null) => {
122
+ void setRawQueryBrowserMode(mode);
123
+ },
124
+ [setRawQueryBrowserMode]
125
+ );
123
126
 
124
127
  const profileFilterDefaults = externalProfilerComponent?.profileFilterDefaults as
125
128
  | ProfileFilter[]
@@ -222,27 +225,25 @@ const ProfileSelector = ({
222
225
  const selectedProfileName = query.profileName();
223
226
 
224
227
  const setQueryExpression = (updateTs = false): void => {
225
- batchUpdates(() => {
226
- if (onSearchHook != null) {
227
- onSearchHook();
228
- }
229
- // When updateTs is true, re-evaluate the time range to current values
230
- if (updateTs) {
231
- // Force re-evaluation of time range (important for relative ranges like "last 15 minutes")
232
- const currentFrom = timeRangeSelection.getFromMs(true);
233
- const currentTo = timeRangeSelection.getToMs(true);
234
- const currentRangeKey = timeRangeSelection.getRangeKey();
235
- // Commit with refreshed time range
236
- commitDraft({
237
- from: currentFrom,
238
- to: currentTo,
239
- timeSelection: currentRangeKey,
240
- });
241
- } else {
242
- // Commit the draft with existing values
243
- commitDraft();
244
- }
245
- });
228
+ if (onSearchHook != null) {
229
+ onSearchHook();
230
+ }
231
+ // When updateTs is true, re-evaluate the time range to current values
232
+ if (updateTs) {
233
+ // Force re-evaluation of time range (important for relative ranges like "last 15 minutes")
234
+ const currentFrom = timeRangeSelection.getFromMs(true);
235
+ const currentTo = timeRangeSelection.getToMs(true);
236
+ const currentRangeKey = timeRangeSelection.getRangeKey();
237
+ // Commit with refreshed time range
238
+ commitDraft({
239
+ from: currentFrom,
240
+ to: currentTo,
241
+ timeSelection: currentRangeKey,
242
+ });
243
+ } else {
244
+ // Commit the draft with existing values
245
+ commitDraft();
246
+ }
246
247
  };
247
248
 
248
249
  const setMatchersString = (matchers: string): void => {
@@ -147,6 +147,11 @@ export const useAutoQuerySelector = ({
147
147
  let profileType = profileTypesData.types.find(
148
148
  type => type.name === 'parca_agent' && type.sampleType === 'samples' && type.delta
149
149
  );
150
+ if (profileType == null) {
151
+ profileType = profileTypesData.types.find(
152
+ type => type.name === 'go_opentelemetry_io_ebpf_profiler' && type.delta
153
+ );
154
+ }
150
155
  if (profileType == null) {
151
156
  profileType = profileTypesData.types.find(
152
157
  type => type.name === 'otel_profiling_agent_on_cpu' && type.delta
@@ -97,6 +97,10 @@ export const wellKnownProfiles: WellKnownProfiles = {
97
97
  name: 'On-CPU Samples',
98
98
  help: 'On CPU profile samples observed by the Otel Profiling Agent.',
99
99
  },
100
+ 'go_opentelemetry_io_ebpf_profiler:samples:count:cpu:nanoseconds:delta': {
101
+ name: 'On-CPU',
102
+ help: 'On CPU profile samples as observed by the OpenTelemetry eBPF Profiler.',
103
+ },
100
104
  'parca_agent:samples:count:cpu:nanoseconds:delta': {
101
105
  name: 'On-CPU',
102
106
  help: 'On CPU profile samples as observed by the Parca Agent.',
@@ -11,19 +11,23 @@
11
11
  // See the License for the specific language governing permissions and
12
12
  // limitations under the License.
13
13
 
14
- import {Select, useURLState} from '@parca/components';
14
+ import {useQueryState} from 'nuqs';
15
+
16
+ import {Select} from '@parca/components';
15
17
 
16
18
  import {
17
19
  FIELD_CUMULATIVE,
18
20
  FIELD_DIFF,
19
21
  FIELD_FUNCTION_NAME,
20
22
  } from '../../../ProfileFlameGraph/FlameGraphArrow';
23
+ import {stringParam} from '../../../hooks/urlParsers';
21
24
  import {useProfileViewContext} from '../../context/ProfileViewContext';
22
25
 
23
26
  const SortByDropdown = (): React.JSX.Element => {
24
- const [storeSortBy, setStoreSortBy] = useURLState('sort_by', {
25
- defaultValue: FIELD_FUNCTION_NAME,
26
- });
27
+ const [storeSortBy, setStoreSortBy] = useQueryState(
28
+ 'sort_by',
29
+ stringParam.withDefault(FIELD_FUNCTION_NAME)
30
+ );
27
31
 
28
32
  const {compareMode} = useProfileViewContext();
29
33
 
@@ -70,8 +74,8 @@ const SortByDropdown = (): React.JSX.Element => {
70
74
  },
71
75
  },
72
76
  ]}
73
- selectedKey={storeSortBy as string}
74
- onSelection={key => setStoreSortBy(key)}
77
+ selectedKey={storeSortBy}
78
+ onSelection={key => void setStoreSortBy(key)}
75
79
  placeholder={'Sort By'}
76
80
  primary={false}
77
81
  disabled={false}
@@ -16,12 +16,12 @@ import React, {useMemo} from 'react';
16
16
  import {Icon} from '@iconify/react';
17
17
  import cx from 'classnames';
18
18
 
19
- import {useURLState} from '@parca/components';
20
19
  import {USER_PREFERENCES, useCurrentColorProfile, useUserPreference} from '@parca/hooks';
21
20
  import {EVERYTHING_ELSE, selectDarkMode, useAppSelector} from '@parca/store';
22
21
 
23
22
  import {getMappingColors} from '../../ProfileFlameGraph/FlameGraphArrow';
24
23
  import useMappingList from '../../ProfileFlameGraph/FlameGraphArrow/useMappingList';
24
+ import {useColorBy} from '../../hooks/useColorBy';
25
25
  import {useProfileFilters} from './ProfileFilters/useProfileFilters';
26
26
 
27
27
  interface Props {
@@ -37,9 +37,7 @@ const ColorStackLegend = ({mappings, compareMode = false, loading}: Props): Reac
37
37
  USER_PREFERENCES.FLAMEGRAPH_COLOR_PROFILE.key
38
38
  );
39
39
 
40
- const [colorByValue, _] = useURLState('color_by');
41
-
42
- const colorBy = colorByValue === 'binary' || colorByValue === undefined ? 'binary' : 'filename';
40
+ const {colorBy} = useColorBy();
43
41
 
44
42
  const {appliedFilters, removeExcludeBinary, excludeBinary} = useProfileFilters();
45
43
 
@@ -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
  {