From 657a2f6a4e14969f2a82bada821b4a7ec48ed897 Mon Sep 17 00:00:00 2001
From: Manoj Vivek
Date: Tue, 7 Apr 2026 15:50:36 +0530
Subject: [PATCH 1/7] nuqs for URL state management
---
ui/packages/app/web/package.json | 1 +
ui/packages/app/web/src/App.tsx | 27 +-
ui/packages/app/web/src/pages/index.tsx | 33 +-
ui/packages/shared/components/package.json | 1 +
.../components/src/hooks/URLState/README.md | 104 ---
.../src/hooks/URLState/index.test.tsx | 650 ------------------
.../components/src/hooks/URLState/index.tsx | 364 ----------
.../components/src/hooks/URLState/utils.ts | 91 ---
ui/packages/shared/components/src/index.tsx | 1 -
ui/packages/shared/profile/package.json | 1 +
.../useGraphTooltipMetaInfo/index.ts | 28 +-
.../ProfileExplorerCompare.tsx | 13 +-
.../ProfileFlameChart/SamplesStrips/index.tsx | 4 +-
.../profile/src/ProfileFlameChart/index.tsx | 42 +-
.../FlameGraphArrow/ContextMenu.tsx | 27 +-
.../FlameGraphArrow/TextWithEllipsis.tsx | 8 +-
.../profile/src/ProfileFlameGraph/index.tsx | 15 +-
.../profile/src/ProfileMetricsGraph/index.tsx | 14 +-
.../ProfileSelector/MetricsGraphSection.tsx | 15 +-
.../profile/src/ProfileSelector/index.tsx | 57 +-
.../ActionButtons/SortByDropdown.tsx | 16 +-
.../components/ColorStackLegend.tsx | 7 +-
.../components/InvertCallStack/index.tsx | 9 +-
.../useProfileFiltersUrlState.test.tsx | 283 +++-----
.../useProfileFiltersUrlState.ts | 42 +-
.../Toolbars/MultiLevelDropdown.tsx | 49 +-
.../Toolbars/TableColumnsDropdown.tsx | 9 +-
.../ProfileView/components/Toolbars/index.tsx | 6 +-
.../components/ViewSelector/index.tsx | 31 +-
.../ProfileView/context/DashboardContext.tsx | 23 +-
.../hooks/useResetFlameGraphState.ts | 10 +-
.../hooks/useResetStateOnProfileTypeChange.ts | 50 +-
.../hooks/useResetStateOnSeriesChange.ts | 24 +-
.../hooks/useVisualizationState.ts | 123 ++--
.../profile/src/ProfileViewWithData.tsx | 69 +-
.../shared/profile/src/Sandwich/index.tsx | 7 +-
.../shared/profile/src/SourceView/index.tsx | 6 +-
.../src/SourceView/useSelectedLineRange.ts | 53 +-
.../shared/profile/src/Table/MoreDropdown.tsx | 22 +-
.../profile/src/Table/TableContextMenu.tsx | 25 +-
.../src/Table/hooks/useTableConfiguration.tsx | 7 +-
.../shared/profile/src/Table/index.tsx | 31 +-
.../shared/profile/src/TopTable/index.tsx | 8 +-
.../shared/profile/src/hooks/urlParsers.ts | 39 ++
.../profile/src/hooks/useCompareModeMeta.ts | 152 ++--
.../profile/src/hooks/useQueryState.test.tsx | 618 ++++++++---------
.../shared/profile/src/hooks/useQueryState.ts | 254 +++----
ui/packages/shared/profile/src/index.tsx | 15 -
ui/packages/shared/profile/src/useSumBy.ts | 6 +-
ui/pnpm-lock.yaml | 83 ++-
50 files changed, 1108 insertions(+), 2465 deletions(-)
delete mode 100644 ui/packages/shared/components/src/hooks/URLState/README.md
delete mode 100644 ui/packages/shared/components/src/hooks/URLState/index.test.tsx
delete mode 100644 ui/packages/shared/components/src/hooks/URLState/index.tsx
delete mode 100644 ui/packages/shared/components/src/hooks/URLState/utils.ts
create mode 100644 ui/packages/shared/profile/src/hooks/urlParsers.ts
diff --git a/ui/packages/app/web/package.json b/ui/packages/app/web/package.json
index 6dea69f832b..2e0e925ed53 100644
--- a/ui/packages/app/web/package.json
+++ b/ui/packages/app/web/package.json
@@ -44,6 +44,7 @@
"immer": "9.0.21",
"isomorphic-unfetch": "3.1.0",
"lodash.debounce": "4.0.8",
+ "nuqs": "^2.4.1",
"lodash.throttle": "^4.1.1",
"moment": "2.30.1",
"postcss": "8.5.8",
diff --git a/ui/packages/app/web/src/App.tsx b/ui/packages/app/web/src/App.tsx
index f9482fa2ad6..fe1f4bcc808 100644
--- a/ui/packages/app/web/src/App.tsx
+++ b/ui/packages/app/web/src/App.tsx
@@ -11,6 +11,7 @@
// See the License for the specific language governing permissions and
// limitations under the License.
+import {NuqsAdapter} from 'nuqs/adapters/react-router/v6';
import {BrowserRouter, Navigate, Route, Routes} from 'react-router-dom';
import {PersistGate} from 'redux-persist/integration/react';
@@ -68,18 +69,20 @@ const App = () => {
-
-
-
-
- } />
- } />
- } />
- } />
- } />
-
-
-
+
+
+
+
+
+ } />
+ } />
+ } />
+ } />
+ } />
+
+
+
+
diff --git a/ui/packages/app/web/src/pages/index.tsx b/ui/packages/app/web/src/pages/index.tsx
index 063d3c38d3d..fab42ee4524 100644
--- a/ui/packages/app/web/src/pages/index.tsx
+++ b/ui/packages/app/web/src/pages/index.tsx
@@ -17,8 +17,8 @@ import {GrpcWebFetchTransport} from '@protobuf-ts/grpcweb-transport';
import {useNavigate} from 'react-router-dom';
import {QueryServiceClient} from '@parca/client';
-import {ParcaContextProvider, Spinner, URLStateProvider} from '@parca/components';
-import {DEFAULT_PROFILE_EXPLORER_PARAM_VALUES, ProfileExplorer} from '@parca/profile';
+import {ParcaContextProvider, Spinner} from '@parca/components';
+import {ProfileExplorer} from '@parca/profile';
import {selectDarkMode, useAppSelector} from '@parca/store';
import {convertToQueryParams} from '@parca/utilities';
@@ -47,24 +47,19 @@ const Profiles = () => {
);
return (
-
-
-
-
-
+
+
);
};
diff --git a/ui/packages/shared/components/package.json b/ui/packages/shared/components/package.json
index e55e787d603..953f128ff7c 100644
--- a/ui/packages/shared/components/package.json
+++ b/ui/packages/shared/components/package.json
@@ -36,6 +36,7 @@
"d3-selection": "3.0.0",
"graphviz-wasm": "3.0.2",
"lodash": "^4.17.21",
+ "nuqs": "^2.4.1",
"moment-timezone": "^0.6.0",
"react-datepicker": "6.9.0",
"react-popper": "^2.3.0",
diff --git a/ui/packages/shared/components/src/hooks/URLState/README.md b/ui/packages/shared/components/src/hooks/URLState/README.md
deleted file mode 100644
index 6aacf319469..00000000000
--- a/ui/packages/shared/components/src/hooks/URLState/README.md
+++ /dev/null
@@ -1,104 +0,0 @@
-# URLState Hook Usage Guide
-
-The `useURLState` hook provides a simple way to sync component state with URL query parameters. It now includes built-in batching support for efficient URL updates.
-
-## Basic Usage
-
-```tsx
-import {useURLState} from '@parca/components';
-
-function MyComponent() {
- const [colorBy, setColorBy] = useURLState('color_by', {
- defaultValue: 'function',
- });
-
- const [groupBy, setGroupBy] = useURLState('group_by', {
- defaultValue: ['function_name'],
- alwaysReturnArray: true,
- });
-
- // Use the state values and setters as normal
- return (
-
-
-
- );
-}
-```
-
-## Batching Multiple Updates
-
-When you need to update multiple URL parameters simultaneously, use `useURLStateBatch` to ensure a single URL update:
-
-```tsx
-import {useURLState, useURLStateBatch} from '@parca/components';
-
-function ProfileFilters() {
- const [colorBy, setColorBy] = useURLState('color_by');
- const [groupBy, setGroupBy] = useURLState('group_by', {
- alwaysReturnArray: true,
- });
- const [view, setView] = useURLState('view');
-
- // Get the batch function
- const batchUpdates = useURLStateBatch();
-
- const handleComplexFilterChange = () => {
- // Batch multiple URL updates into a single navigation
- batchUpdates(() => {
- setColorBy('filename');
- setGroupBy(['function_name', 'filename']);
- setView('table');
- });
- // Results in ONE URL update instead of three!
- };
-
- return ;
-}
-```
-
-## Key Features
-
-### Automatic URL Synchronization
-
-- All URL updates are now handled centrally by the `URLStateProvider`
-- Individual hooks only manage state; URL sync happens automatically
-- Built-in debouncing prevents excessive URL updates
-
-### Batching Support
-
-- Use `batchUpdates` to group multiple parameter changes
-- Prevents multiple browser history entries
-- Improves performance for complex state updates
-- Essential for maintaining URL coherence when multiple related parameters change
-
-## Migration from Direct Navigation
-
-Previously, the ProfileSelector component managed URL updates directly:
-
-With the new approach, you can use individual `useURLState` hooks with batching:
-
-```tsx
-// New approach - automatic URL sync with batching
-const [expression, setExpression] = useURLState('expression_a');
-const [from, setFrom] = useURLState('from_a');
-const [to, setTo] = useURLState('to_a');
-const batchUpdates = useURLStateBatch();
-
-const selectQuery = (q: QuerySelection): void => {
- batchUpdates(() => {
- setExpression(q.expression);
- setFrom(q.from.toString());
- setTo(q.to.toString());
- // All updates result in a single URL change
- });
-};
-```
-
-## Benefits
-
-1. **Simpler Code**: No need to manually construct URL parameter objects
-2. **Better Performance**: Batching prevents multiple rapid URL updates
-3. **Cleaner History**: One history entry instead of multiple for related changes
-4. **Type Safety**: Each parameter is individually typed
-5. **Easier Testing**: URL synchronization logic is centralized and testable
diff --git a/ui/packages/shared/components/src/hooks/URLState/index.test.tsx b/ui/packages/shared/components/src/hooks/URLState/index.test.tsx
deleted file mode 100644
index 8740b3e1f8c..00000000000
--- a/ui/packages/shared/components/src/hooks/URLState/index.test.tsx
+++ /dev/null
@@ -1,650 +0,0 @@
-// Copyright 2022 The Parca Authors
-// Licensed under the Apache License, Version 2.0 (the "License");
-// you may not use this file except in compliance with the License.
-// You may obtain a copy of the License at
-//
-// http://www.apache.org/licenses/LICENSE-2.0
-//
-// Unless required by applicable law or agreed to in writing, software
-// distributed under the License is distributed on an "AS IS" BASIS,
-// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
-// See the License for the specific language governing permissions and
-// limitations under the License.
-
-import {ReactNode, act} from 'react';
-
-// eslint-disable-next-line import/named
-import {renderHook, waitFor} from '@testing-library/react';
-import {beforeEach, describe, expect, it, vi} from 'vitest';
-
-import {
- JSONParser,
- JSONSerializer,
- URLStateProvider,
- useURLState,
- useURLStateBatch,
- useURLStateCustom,
-} from './index';
-
-// Mock the navigate function
-const mockNavigateTo = vi.fn();
-
-// Mock window.location
-const mockLocation = {
- pathname: '/test',
- search: '',
-};
-
-// Mock the getQueryParamsFromURL function to parse our mock search string
-vi.mock('./utils', async () => {
- const actual = await vi.importActual('./utils');
- return {
- ...actual,
- getQueryParamsFromURL: () => {
- if (mockLocation.search === '') return {};
- const params = new URLSearchParams(mockLocation.search);
- const result: Record = {};
- for (const [key, value] of params.entries()) {
- // Handle decoding
- const decodedValue = decodeURIComponent(value);
- const existing = result[key];
- if (existing !== undefined) {
- // Convert to array if multiple values
- result[key] = Array.isArray(existing)
- ? [...existing, decodedValue]
- : [existing, decodedValue];
- } else {
- result[key] = decodedValue;
- }
- }
- return result;
- },
- };
-});
-
-// Helper to create wrapper with URLStateProvider
-const createWrapper = (
- paramPreferences = {}
-): (({children}: {children: ReactNode}) => JSX.Element) => {
- const Wrapper = ({children}: {children: ReactNode}): JSX.Element => (
-
- {children}
-
- );
- Wrapper.displayName = 'URLStateProviderWrapper';
- return Wrapper;
-};
-
-describe('URLState Hooks', () => {
- beforeEach(() => {
- // Reset mocks before each test
- mockNavigateTo.mockClear();
-
- // Mock window.location
- Object.defineProperty(window, 'location', {
- value: mockLocation,
- writable: true,
- });
-
- // Reset search params
- mockLocation.search = '';
- });
-
- describe('useURLState', () => {
- it('should initialize with default value when no URL param exists', () => {
- const {result} = renderHook(() => useURLState('testParam', {defaultValue: 'defaultValue'}), {
- wrapper: createWrapper(),
- });
-
- const [value] = result.current;
- expect(value).toBe('defaultValue');
- });
-
- it('should update state and trigger URL navigation on setter call', async () => {
- const {result} = renderHook(() => useURLState('testParam'), {wrapper: createWrapper()});
-
- const [, setParam] = result.current;
-
- act(() => {
- setParam('newValue');
- });
-
- // Wait for the microtask to complete
- await waitFor(() => {
- expect(mockNavigateTo).toHaveBeenCalledWith(
- '/test',
- {testParam: 'newValue'},
- {replace: true}
- );
- });
-
- // Check that state is updated
- const [value] = result.current;
- expect(value).toBe('newValue');
- });
-
- it('should handle array values correctly', () => {
- const {result} = renderHook(
- () =>
- useURLState('tags', {
- defaultValue: ['tag1', 'tag2'],
- alwaysReturnArray: true,
- }),
- {wrapper: createWrapper()}
- );
-
- const [value] = result.current;
- expect(value).toEqual(['tag1', 'tag2']);
- });
-
- it('should return single value when array has one item and alwaysReturnArray is false', () => {
- mockLocation.search = '?item=single';
-
- const {result} = renderHook(() => useURLState('item', {alwaysReturnArray: false}), {
- wrapper: createWrapper(),
- });
-
- const [value] = result.current;
- expect(value).toBe('single');
- });
-
- it('should always return array when alwaysReturnArray is true', () => {
- // Set up initial state with a single string value
- mockLocation.search = '';
-
- const {result} = renderHook(
- () =>
- useURLState('item', {
- defaultValue: ['single'],
- alwaysReturnArray: true,
- }),
- {wrapper: createWrapper()}
- );
-
- const [value] = result.current;
- expect(value).toEqual(['single']);
- });
- });
-
- describe('useURLStateBatch', () => {
- it('should batch multiple state updates into a single URL navigation', async () => {
- // Create a test component that uses multiple URL states
- const TestComponent = (): {
- color: string | string[] | undefined;
- size: string | string[] | undefined;
- setColor: (val: string | string[] | undefined) => void;
- setSize: (val: string | string[] | undefined) => void;
- batchUpdates: (callback: () => void) => void;
- } => {
- const [color, setColor] = useURLState('color');
- const [size, setSize] = useURLState('size');
- const batchUpdates = useURLStateBatch();
-
- return {
- color,
- size,
- setColor,
- setSize,
- batchUpdates,
- };
- };
-
- const {result} = renderHook(() => TestComponent(), {
- wrapper: createWrapper(),
- });
-
- act(() => {
- result.current.batchUpdates(() => {
- result.current.setColor('red');
- result.current.setSize('large');
- });
- });
-
- // Wait for the batch to complete
- await waitFor(() => {
- // Should only navigate once with both parameters
- expect(mockNavigateTo).toHaveBeenCalledTimes(1);
- expect(mockNavigateTo).toHaveBeenCalledWith(
- '/test',
- {color: 'red', size: 'large'},
- {replace: true}
- );
- });
-
- // Check that both states are updated
- expect(result.current.color).toBe('red');
- expect(result.current.size).toBe('large');
- });
-
- it('should handle nested batch updates correctly - multiple levels of nesting', async () => {
- // This test simulates real-world scenarios like toggleGroupBy calling resetFlameGraphState,
- // where both functions use batchUpdates, testing 2 levels of nesting
- // eslint-disable-next-line @typescript-eslint/explicit-function-return-type
- const TestComponent = () => {
- const [param1, setParam1] = useURLState('param1');
- const [param2, setParam2] = useURLState('param2');
- const [param3, setParam3] = useURLState('param3');
- const [param4, setParam4] = useURLState('param4');
- const [param5, setParam5] = useURLState('param5');
- const [param6, setParam6] = useURLState('param6');
- const batchUpdates = useURLStateBatch();
-
- // Level 2 nesting - deepest function
- // eslint-disable-next-line @typescript-eslint/explicit-function-return-type
- const deeplyNestedFunction = () => {
- batchUpdates(() => {
- setParam5('value5');
- setParam6('value6');
- });
- };
-
- // Level 1 nesting - calls another batched function
- // eslint-disable-next-line @typescript-eslint/explicit-function-return-type
- const innerBatchedFunction = () => {
- batchUpdates(() => {
- setParam3('value3');
- setParam4('value4');
- // Call another batched function
- deeplyNestedFunction();
- });
- };
-
- return {
- param1,
- param2,
- param3,
- param4,
- param5,
- param6,
- setParam1,
- setParam2,
- innerBatchedFunction,
- batchUpdates,
- };
- };
-
- const {result} = renderHook(() => TestComponent(), {
- wrapper: createWrapper(),
- });
-
- // Outer batchUpdates that calls nested functions which also use batchUpdates
- act(() => {
- result.current.batchUpdates(() => {
- result.current.setParam1('value1');
- result.current.setParam2('value2');
- // This calls another function that internally uses batchUpdates
- // which in turn calls another function that also uses batchUpdates
- result.current.innerBatchedFunction();
- });
- });
-
- await waitFor(() => {
- // Critical: Should only navigate ONCE even with 2 levels of nested batchUpdates
- expect(mockNavigateTo).toHaveBeenCalledTimes(1);
- // All parameters from outer, inner, and deeply nested batches should be in single navigation
- expect(mockNavigateTo).toHaveBeenCalledWith(
- '/test',
- {
- param1: 'value1',
- param2: 'value2',
- param3: 'value3',
- param4: 'value4',
- param5: 'value5',
- param6: 'value6',
- },
- {replace: true}
- );
- });
-
- // Verify all state is updated correctly
- expect(result.current.param1).toBe('value1');
- expect(result.current.param2).toBe('value2');
- expect(result.current.param3).toBe('value3');
- expect(result.current.param4).toBe('value4');
- expect(result.current.param5).toBe('value5');
- expect(result.current.param6).toBe('value6');
- });
- });
-
- describe('useURLStateCustom', () => {
- it('should parse and stringify custom data types', () => {
- const customData = {foo: 'bar', count: 42};
-
- const {result} = renderHook(
- () =>
- useURLStateCustom('customData', {
- parse: JSONParser,
- stringify: JSONSerializer,
- defaultValue: JSON.stringify(customData),
- }),
- {wrapper: createWrapper()}
- );
-
- const [value] = result.current;
- expect(value).toEqual(customData);
- });
-
- it('should handle custom serialization for complex objects', async () => {
- const {result} = renderHook(
- () =>
- useURLStateCustom<{items: string[]; enabled: boolean}>('config', {
- parse: JSONParser,
- stringify: JSONSerializer,
- }),
- {wrapper: createWrapper()}
- );
-
- const [, setConfig] = result.current;
-
- act(() => {
- setConfig({items: ['a', 'b', 'c'], enabled: true});
- });
-
- await waitFor(() => {
- expect(mockNavigateTo).toHaveBeenCalledWith(
- '/test',
- {config: '{"items":["a","b","c"],"enabled":true}'},
- {replace: true}
- );
- });
- });
- });
-
- describe('Real-world use cases', () => {
- it('should handle dashboard panel management', async () => {
- // Simulate ViewSelector component behavior
- const {result: dashboardResult} = renderHook(
- () =>
- useURLState('dashboard_items', {
- defaultValue: ['flamegraph'],
- alwaysReturnArray: true,
- }),
- {wrapper: createWrapper()}
- );
-
- const [dashboardItems, setDashboardItems] = dashboardResult.current;
- expect(dashboardItems).toEqual(['flamegraph']);
-
- // Add a new panel
- act(() => {
- setDashboardItems([...dashboardItems, 'table']);
- });
-
- await waitFor(() => {
- expect(mockNavigateTo).toHaveBeenCalledWith(
- '/test',
- {dashboard_items: 'flamegraph,table'},
- {replace: true}
- );
- });
- });
-
- it('should handle complex filter updates with batching', async () => {
- // Simulate ProfileSelector component behavior
- // eslint-disable-next-line @typescript-eslint/explicit-function-return-type
- const TestComponent = () => {
- const [colorBy, setColorBy] = useURLState('color_by', {defaultValue: 'function'});
- const [groupBy, setGroupBy] = useURLState('group_by', {
- defaultValue: ['function_name'],
- alwaysReturnArray: true,
- });
- const batchUpdates = useURLStateBatch();
-
- return {
- colorBy,
- groupBy,
- setColorBy,
- setGroupBy,
- batchUpdates,
- };
- };
-
- const {result} = renderHook(() => TestComponent(), {
- wrapper: createWrapper(),
- });
-
- // Simulate a complex filter change that updates multiple params
- act(() => {
- result.current.batchUpdates(() => {
- result.current.setColorBy('filename');
- result.current.setGroupBy(['function_name', 'filename']);
- });
- });
-
- await waitFor(() => {
- expect(mockNavigateTo).toHaveBeenCalledTimes(1);
- expect(mockNavigateTo).toHaveBeenCalledWith(
- '/test',
- {
- color_by: 'filename',
- group_by: 'function_name,filename',
- },
- {replace: true}
- );
- });
- });
-
- it('should not update URL for default values', async () => {
- const paramPreferences = {
- view: {defaultValue: 'flamegraph'},
- sort: {defaultValue: 'cumulative'},
- };
-
- const {result} = renderHook(() => useURLState('view'), {
- wrapper: createWrapper(paramPreferences),
- });
-
- const [, setView] = result.current;
-
- // Set to default value
- act(() => {
- setView('flamegraph');
- });
-
- await waitFor(() => {
- // Should still be called but with empty params (sanitized)
- expect(mockNavigateTo).toHaveBeenCalledWith('/test', {}, {replace: true});
- });
- });
-
- it('should handle rapid successive updates', async () => {
- const {result} = renderHook(() => useURLState('rapidParam'), {wrapper: createWrapper()});
-
- const [, setParam] = result.current;
-
- // Rapid successive updates
- act(() => {
- setParam('value1');
- setParam('value2');
- setParam('value3');
- });
-
- await waitFor(() => {
- // Due to the setTimeout(0) debouncing, we expect the last value
- expect(mockNavigateTo).toHaveBeenLastCalledWith(
- '/test',
- {rapidParam: 'value3'},
- {replace: true}
- );
- });
- });
- });
-
- describe('URL Parameter Preservation', () => {
- it('should preserve other query parameters when resetting specific ones', async () => {
- // Simulate existing URL parameters
- mockLocation.search =
- '?expression_a=process_cpu%7B%7D&from_a=1234567890&to_a=9876543210&time_selection_a=1h&group_by=existing_group&cur_path=/existing/path';
-
- // eslint-disable-next-line @typescript-eslint/explicit-function-return-type
- const TestComponent = () => {
- const [groupBy, setGroupBy] = useURLState('group_by');
- const [curPath, setCurPath] = useURLState('cur_path');
- const [expression] = useURLState('expression_a');
- const [from] = useURLState('from_a');
- const batchUpdates = useURLStateBatch();
-
- return {
- groupBy,
- curPath,
- expression,
- from,
- resetProfileTypeState: () => {
- batchUpdates(() => {
- setGroupBy(undefined);
- setCurPath(undefined);
- });
- },
- };
- };
-
- const {result} = renderHook(() => TestComponent(), {
- wrapper: createWrapper(),
- });
-
- // Verify initial values
- expect(result.current.expression).toBe('process_cpu{}');
- expect(result.current.from).toBe('1234567890');
-
- // Perform the reset
- act(() => {
- result.current.resetProfileTypeState();
- });
-
- await waitFor(() => {
- expect(mockNavigateTo).toHaveBeenCalledTimes(1);
- const [, params] = mockNavigateTo.mock.calls[0];
-
- // Critical: Check that query parameters are preserved
- expect(params).toHaveProperty('expression_a', 'process_cpu{}');
- expect(params).toHaveProperty('from_a', '1234567890');
- expect(params).toHaveProperty('to_a', '9876543210');
- expect(params).toHaveProperty('time_selection_a', '1h');
-
- // These should be removed
- expect(params).not.toHaveProperty('group_by');
- expect(params).not.toHaveProperty('cur_path');
- });
- });
-
- it('should preserve unmanaged parameters during single state updates', async () => {
- // Set up URL with both managed and unmanaged parameters
- mockLocation.search =
- '?managed=old_value&unmanaged=should_persist&another_unmanaged=also_persists';
-
- const {result} = renderHook(() => useURLState('managed'), {wrapper: createWrapper()});
-
- const [, setManaged] = result.current;
-
- // Update only the managed parameter
- act(() => {
- setManaged('new_value');
- });
-
- await waitFor(() => {
- expect(mockNavigateTo).toHaveBeenCalledWith(
- '/test',
- {
- managed: 'new_value',
- unmanaged: 'should_persist',
- another_unmanaged: 'also_persists',
- },
- {replace: true}
- );
- });
- });
-
- it('should preserve unmanaged parameters when adding new state', async () => {
- // Start with some unmanaged parameters in URL
- mockLocation.search = '?existing_param=value1&another_param=value2';
-
- const {result} = renderHook(() => useURLState('new_param'), {wrapper: createWrapper()});
-
- const [, setNewParam] = result.current;
-
- // Add a new parameter
- act(() => {
- setNewParam('new_value');
- });
-
- await waitFor(() => {
- expect(mockNavigateTo).toHaveBeenCalledWith(
- '/test',
- {
- existing_param: 'value1',
- another_param: 'value2',
- new_param: 'new_value',
- },
- {replace: true}
- );
- });
- });
-
- it('should handle complex nested objects in unmanaged parameters', async () => {
- // Simulate URL with JSON-encoded objects
- mockLocation.search = '?filter=%7B%22type%22%3A%22cpu%22%2C%22value%22%3A100%7D&managed=test';
-
- const {result} = renderHook(() => useURLState('managed'), {wrapper: createWrapper()});
-
- const [, setManaged] = result.current;
-
- act(() => {
- setManaged('updated');
- });
-
- await waitFor(() => {
- expect(mockNavigateTo).toHaveBeenCalledWith(
- '/test',
- {
- filter: '{"type":"cpu","value":100}',
- managed: 'updated',
- },
- {replace: true}
- );
- });
- });
-
- it('should preserve array parameters not managed by hooks', async () => {
- // URL with array parameters - note that our mock getQueryParamsFromURL processes these
- mockLocation.search = '?tags=tag1&tags=tag2&tags=tag3&managed=value';
-
- const {result} = renderHook(() => useURLState('managed'), {wrapper: createWrapper()});
-
- const [, setManaged] = result.current;
-
- act(() => {
- setManaged('new_value');
- });
-
- await waitFor(() => {
- const [, params] = mockNavigateTo.mock.calls[0];
- // Tags are preserved (the sanitize function converts arrays to comma-separated strings)
- expect(params.tags).toBe('tag1,tag2,tag3');
- expect(params.managed).toBe('new_value');
- });
- });
- });
-
- describe('Error handling', () => {
- it('should throw error when used outside URLStateProvider', () => {
- // Suppress console.error for this test
- const consoleSpy = vi.spyOn(console, 'error').mockImplementation(() => {});
-
- expect(() => {
- renderHook(() => useURLState('param'));
- }).toThrow('useURLState must be used within a URLStateProvider');
-
- consoleSpy.mockRestore();
- });
-
- it('should throw error for useURLStateBatch when used outside URLStateProvider', () => {
- const consoleSpy = vi.spyOn(console, 'error').mockImplementation(() => {});
-
- expect(() => {
- renderHook(() => useURLStateBatch());
- }).toThrow('useURLStateBatch must be used within a URLStateProvider');
-
- consoleSpy.mockRestore();
- });
- });
-});
diff --git a/ui/packages/shared/components/src/hooks/URLState/index.tsx b/ui/packages/shared/components/src/hooks/URLState/index.tsx
deleted file mode 100644
index e9f2583efb9..00000000000
--- a/ui/packages/shared/components/src/hooks/URLState/index.tsx
+++ /dev/null
@@ -1,364 +0,0 @@
-// Copyright 2022 The Parca Authors
-// Licensed under the Apache License, Version 2.0 (the "License");
-// you may not use this file except in compliance with the License.
-// You may obtain a copy of the License at
-//
-// http://www.apache.org/licenses/LICENSE-2.0
-//
-// Unless required by applicable law or agreed to in writing, software
-// distributed under the License is distributed on an "AS IS" BASIS,
-// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
-// See the License for the specific language governing permissions and
-// limitations under the License.
-
-import {
- Dispatch,
- ReactNode,
- SetStateAction,
- createContext,
- useCallback,
- useContext,
- useEffect,
- useMemo,
- useRef,
- useState,
-} from 'react';
-
-import {type NavigateFunction} from '@parca/utilities';
-
-import {
- getQueryParamsFromURL,
- sanitize,
- type ParamPreference,
- type ParamPreferences,
- type ParamValue,
-} from './utils';
-
-export type ParamValueSetter = (val: ParamValue) => void;
-export type {ParamPreferences, ParamPreference};
-
-interface URLState {
- navigateTo: NavigateFunction;
- state: Record;
- setState: Dispatch>>;
- paramPreferences: ParamPreferences;
- batchUpdates: (callback: () => void) => void;
-}
-
-const URLStateContext = createContext(undefined);
-const EMPTY_PREFS = {};
-
-export const URLStateProvider = ({
- children,
- navigateTo,
- paramPreferences = EMPTY_PREFS,
-}: {
- children: ReactNode;
- navigateTo: NavigateFunction;
- paramPreferences?: ParamPreferences;
-}): JSX.Element => {
- const defaultValues = useMemo(() => {
- const defaults: Record = {};
- Object.entries(paramPreferences).forEach(([key, prefs]) => {
- if (prefs.defaultValue !== undefined) {
- defaults[key] = prefs.defaultValue;
- }
- });
- return defaults;
- }, [paramPreferences]);
-
- const [state, setState] = useState>({
- ...defaultValues,
- ...getQueryParamsFromURL(paramPreferences),
- });
-
- const isInitialMount = useRef(true);
- const isBatchingRef = useRef(false);
- const batchTimeoutRef = useRef();
- const urlUpdateTimeoutRef = useRef();
- const lastSyncedURLRef = useRef(window.location.search);
-
- // Sync state from URL when it changes externally (e.g., clicking nav links)
- // Runs on every render of the provider to catch URL changes
- // eslint-disable-next-line react-hooks/exhaustive-deps
- useEffect(() => {
- const currentURL = window.location.search;
-
- // Normalize URLs for comparison (+ and %20 both represent spaces)
- const normalizedCurrentURL = currentURL.replace(/\+/g, '%20');
- const normalizedLastSyncedURL = lastSyncedURLRef.current.replace(/\+/g, '%20');
-
- if (normalizedCurrentURL === normalizedLastSyncedURL) {
- return;
- }
-
- lastSyncedURLRef.current = currentURL;
-
- const urlParams = getQueryParamsFromURL(paramPreferences);
- const newState = {
- ...defaultValues,
- ...urlParams,
- };
- setState(newState);
- });
-
- // Track state changes and sync to URL
- useEffect(() => {
- // Skip initial mount to avoid unnecessary navigation as the state was just initialized from URL
- if (isInitialMount.current) {
- isInitialMount.current = false;
- return;
- }
-
- // If we're batching, don't navigate yet - we'll do it at the end of the batch
- if (isBatchingRef.current) {
- return;
- }
-
- // Clear any existing timeout
- if (urlUpdateTimeoutRef.current !== undefined) {
- clearTimeout(urlUpdateTimeoutRef.current);
- }
-
- // Debounce URL updates with a microtask
- urlUpdateTimeoutRef.current = setTimeout(() => {
- // ALWAYS merge with existing URL params to preserve them
- const currentParams = getQueryParamsFromURL(paramPreferences);
- const mergedParams = {...currentParams, ...state};
-
- const sanitizedParams = sanitize(mergedParams, paramPreferences);
- navigateTo(window.location.pathname, sanitizedParams, {replace: true});
-
- // Update ref to match the URL we just set (to avoid re-syncing)
- const queryString = new URLSearchParams(sanitizedParams as Record).toString();
- lastSyncedURLRef.current = queryString !== '' ? `?${queryString}` : '';
- }, 0);
-
- return () => {
- if (urlUpdateTimeoutRef.current !== undefined) {
- clearTimeout(urlUpdateTimeoutRef.current);
- }
- };
- }, [state, navigateTo, paramPreferences]);
-
- // Batch updates function
- const batchUpdates = useCallback(
- (callback: () => void) => {
- // Track if we were already batching before this call (for nested batching)
- const wasAlreadyBatching = isBatchingRef.current;
-
- isBatchingRef.current = true;
-
- // Execute all state updates synchronously
- callback();
-
- // If we were already batching, this is a nested call - don't schedule a new timeout
- // Let the outermost batchUpdates handle the URL navigation
- if (wasAlreadyBatching) {
- return;
- }
-
- // Clear any existing timeout
- if (batchTimeoutRef.current !== undefined) {
- clearTimeout(batchTimeoutRef.current);
- }
-
- // Use setState to capture the final state after all updates
- // This ensures we have the latest state including all batched changes
- setState(currentState => {
- // Don't actually change the state, just use this to read the latest value
- // Schedule the batch to complete and trigger URL update
- batchTimeoutRef.current = setTimeout(() => {
- isBatchingRef.current = false;
-
- // Navigate with the latest state PLUS existing URL params
- // ALWAYS merge with existing URL params to preserve them
- const currentParams = getQueryParamsFromURL(paramPreferences);
- const mergedParams = {...currentParams, ...currentState};
-
- const sanitizedParams = sanitize(mergedParams, paramPreferences);
- navigateTo(window.location.pathname, sanitizedParams, {replace: true});
-
- // Update ref to match the URL we just set (to avoid re-syncing)
- const queryString = new URLSearchParams(
- sanitizedParams as Record
- ).toString();
- lastSyncedURLRef.current = queryString !== '' ? `?${queryString}` : '';
- }, 0);
-
- return currentState; // Return unchanged state
- });
- },
- [paramPreferences, navigateTo]
- );
-
- const contextValue = useMemo(
- () => ({
- navigateTo,
- state,
- setState,
- paramPreferences,
- batchUpdates,
- }),
- [navigateTo, state, setState, paramPreferences, batchUpdates]
- );
-
- return {children};
-};
-
-interface Options {
- defaultValue?: string | string[];
- debugLog?: boolean;
- alwaysReturnArray?: boolean;
-}
-
-export const useURLState = (
- param: string,
- _options?: Options
-): [T, ParamValueSetter] => {
- const context = useContext(URLStateContext);
- if (context === undefined) {
- throw new Error('useURLState must be used within a URLStateProvider');
- }
-
- const {debugLog, defaultValue, alwaysReturnArray} = _options ?? {};
-
- const {state, setState} = context;
-
- const setParam: ParamValueSetter = useCallback(
- (val: ParamValue) => {
- if (debugLog === true) {
- console.log('useURLState setParam', param, val);
- }
-
- // Just update state - Provider handles URL sync automatically!
- setState(currentState => ({
- ...currentState,
- [param]: val,
- }));
- },
- [param, setState, debugLog]
- );
-
- if (debugLog === true) {
- // eslint-disable-next-line react-hooks/rules-of-hooks
- useEffect(() => {
- console.log('useURLState state change', param, state[param]);
-
- // eslint-disable-next-line react-hooks/exhaustive-deps
- }, [state[param]]);
- }
-
- const value = useMemo(() => {
- if (typeof state[param] === 'string') {
- if (alwaysReturnArray === true) {
- if (debugLog === true) {
- console.log('useURLState returning single string value as array for param', param, [
- state[param],
- ]);
- }
- return [state[param]] as ParamValue;
- }
- if (debugLog === true) {
- console.log('useURLState returning string value for param', param, state[param]);
- }
- return state[param];
- } else if (state[param] != null && Array.isArray(state[param])) {
- if (state[param]?.length === 1 && alwaysReturnArray !== true) {
- if (debugLog === true) {
- console.log(
- 'useURLState returning first array value as string for param',
- param,
- state[param][0]
- );
- }
- return state[param]?.[0] as ParamValue;
- } else {
- if (debugLog === true) {
- console.log('useURLState returning array value for param', param, state[param]);
- }
- return state[param];
- }
- }
- }, [state, param, alwaysReturnArray, debugLog]);
-
- if (value == null) {
- if (debugLog === true) {
- console.log(
- 'useURLState returning defaultValue for param',
- param,
- defaultValue,
- window.location.href
- );
- }
- }
-
- return [(value ?? defaultValue) as T, setParam];
-};
-
-export interface OptionsCustom {
- parse: (val: ParamValue) => T;
- stringify: (val: T) => ParamValue;
-}
-
-export type ParamValueSetterCustom = (val: T) => void;
-
-export const useURLStateCustom = (
- param: string,
- {parse, stringify, ..._options}: Options & OptionsCustom
-): [T, ParamValueSetterCustom] => {
- const [urlValue, setURLValue] = useURLState(param, _options);
-
- const val = useMemo(() => {
- if (urlValue == null || (Array.isArray(urlValue) && urlValue.length === 0)) {
- return undefined as T;
- }
- return parse(urlValue);
- }, [parse, urlValue]);
-
- const setVal = useCallback(
- (val: T) => {
- setURLValue(stringify(val));
- },
- [setURLValue, stringify]
- );
-
- return [val, setVal];
-};
-
-export const JSONSerializer = (val: object): string => {
- return JSON.stringify(val, (_, v) => (typeof v === 'bigint' ? v.toString() : v));
-};
-
-export const JSONParser = (val: ParamValue): T => {
- return JSON.parse(val as string);
-};
-
-export const NumberParser = (val: ParamValue): number => {
- if (val == null || val === '' || val === 'undefined') {
- return 0;
- }
- if (Array.isArray(val)) {
- return val.length > 0 ? Number(val[0]) : 0;
- }
- return Number(val);
-};
-
-export const NumberSerializer = (val: number): string => {
- if (val == null) {
- return '';
- }
- return String(val);
-};
-
-// Hook to access batch functionality
-export const useURLStateBatch = (): ((callback: () => void) => void) => {
- const context = useContext(URLStateContext);
- if (context === undefined) {
- throw new Error('useURLStateBatch must be used within a URLStateProvider');
- }
-
- return context.batchUpdates;
-};
-
-export default URLStateContext;
diff --git a/ui/packages/shared/components/src/hooks/URLState/utils.ts b/ui/packages/shared/components/src/hooks/URLState/utils.ts
deleted file mode 100644
index 6a3a2ed52a8..00000000000
--- a/ui/packages/shared/components/src/hooks/URLState/utils.ts
+++ /dev/null
@@ -1,91 +0,0 @@
-// Copyright 2022 The Parca Authors
-// Licensed under the Apache License, Version 2.0 (the "License");
-// you may not use this file except in compliance with the License.
-// You may obtain a copy of the License at
-//
-// http://www.apache.org/licenses/LICENSE-2.0
-//
-// Unless required by applicable law or agreed to in writing, software
-// distributed under the License is distributed on an "AS IS" BASIS,
-// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
-// See the License for the specific language governing permissions and
-// limitations under the License.
-
-import {parseParams} from '@parca/utilities';
-
-export type ParamValue = string | string[] | undefined;
-
-export interface ParamPreference {
- defaultValue?: ParamValue;
- splitOnCommas?: boolean; // Default: false
-}
-
-export type ParamPreferences = Record;
-
-export const getQueryParamsFromURL = (
- preferences: ParamPreferences = {}
-): Record => {
- if (typeof window === 'undefined') {
- return {};
- }
-
- return parseParams(window.location.search, false, preferences);
-};
-
-const isEmpty = (val: string | string[] | undefined): boolean => {
- return val === undefined || val == null || val === '' || (Array.isArray(val) && val.length === 0);
-};
-
-const isEqual = (a: ParamValue, b: ParamValue): boolean => {
- if (typeof a === 'string' && typeof b === 'string') {
- return decodeURIComponent(a) === decodeURIComponent(b);
- }
-
- if (Array.isArray(a) && Array.isArray(b)) {
- if (a.length !== b.length) {
- return false;
- }
-
- for (let i = 0; i < a.length; i++) {
- if (a[i] !== b[i]) {
- return false;
- }
- }
- return true;
- }
-
- // ['flamegraph'] === 'flamegraph'
- if (Array.isArray(a) && a.length === 1 && typeof b === 'string') {
- return decodeURIComponent(a[0]) === decodeURIComponent(b);
- }
-
- // 'flamegraph' === ['flamegraph']
- if (Array.isArray(b) && b.length === 1 && typeof a === 'string') {
- return decodeURIComponent(b[0]) === decodeURIComponent(a);
- }
-
- if (a === undefined && b === undefined) {
- return true;
- }
-
- return false;
-};
-
-export const sanitize = (
- params: Record,
- preferences: ParamPreferences
-): Record => {
- const sanitized: Record = {};
- for (const [key, value] of Object.entries(params)) {
- const defaultValue = preferences[key]?.defaultValue;
- if (isEmpty(value) || isEqual(value, defaultValue) || value == null) {
- continue;
- }
- if (Array.isArray(value)) {
- sanitized[key] = value.join(',');
- } else {
- sanitized[key] = value;
- }
- }
- return sanitized;
-};
diff --git a/ui/packages/shared/components/src/index.tsx b/ui/packages/shared/components/src/index.tsx
index 7c5d08f76cd..9405b9dbe67 100644
--- a/ui/packages/shared/components/src/index.tsx
+++ b/ui/packages/shared/components/src/index.tsx
@@ -44,7 +44,6 @@ export type {PillVariant, SelectElement, SelectItem};
export * from './CopyToClipboard';
export * from './ParcaContext';
-export * from './hooks/URLState';
export * from './DividerWithLabel';
export {
diff --git a/ui/packages/shared/profile/package.json b/ui/packages/shared/profile/package.json
index 411dbc11ed4..13250763c27 100644
--- a/ui/packages/shared/profile/package.json
+++ b/ui/packages/shared/profile/package.json
@@ -42,6 +42,7 @@
"graphviz-wasm": "3.0.2",
"lodash.throttle": "^4.1.1",
"lz4js": "^0.2.0",
+ "nuqs": "^2.4.1",
"react": "18.3.1",
"react-beautiful-dnd": "^13.1.1",
"react-contexify": "^6.0.0",
diff --git a/ui/packages/shared/profile/src/GraphTooltipArrow/useGraphTooltipMetaInfo/index.ts b/ui/packages/shared/profile/src/GraphTooltipArrow/useGraphTooltipMetaInfo/index.ts
index 54f9e7a0e30..2811898b384 100644
--- a/ui/packages/shared/profile/src/GraphTooltipArrow/useGraphTooltipMetaInfo/index.ts
+++ b/ui/packages/shared/profile/src/GraphTooltipArrow/useGraphTooltipMetaInfo/index.ts
@@ -12,9 +12,10 @@
// limitations under the License.
import {Table} from '@uwdata/flechette';
+import {useQueryState} from 'nuqs';
import {QueryRequest_ReportType} from '@parca/client';
-import {useParcaContext, useURLState} from '@parca/components';
+import {useParcaContext} from '@parca/components';
import {
FIELD_FUNCTION_FILE_NAME,
@@ -30,6 +31,7 @@ import {
import {arrowToString} from '../../ProfileFlameGraph/FlameGraphArrow/utils';
import {ProfileSource} from '../../ProfileSource';
import {useProfileViewContext} from '../../ProfileView/context/ProfileViewContext';
+import {dashboardItemsParser, stringParam} from '../../hooks/urlParsers';
import {useQuery} from '../../useQuery';
interface Props {
@@ -107,28 +109,26 @@ export const useGraphTooltipMetaInfo = ({table, row}: Props): GraphTooltipMetaIn
])
.filter(value => value[1] !== '') as Array<[string, string]>;
- const [dashboardItems, setDashboardItems] = useURLState('dashboard_items', {
- alwaysReturnArray: true,
- });
+ const [dashboardItems, setDashboardItems] = useQueryState(
+ 'dashboard_items',
+ dashboardItemsParser
+ );
- // eslint-disable-next-line @typescript-eslint/no-unused-vars
- const [unusedBuildId, setSourceBuildId] = useURLState('source_buildid');
+ const [_unusedBuildId, setSourceBuildId] = useQueryState('source_buildid', stringParam);
- // eslint-disable-next-line @typescript-eslint/no-unused-vars
- const [unusedFilename, setSourceFilename] = useURLState('source_filename');
+ const [_unusedFilename, setSourceFilename] = useQueryState('source_filename', stringParam);
- // eslint-disable-next-line @typescript-eslint/no-unused-vars
- const [unusedLine, setSourceLine] = useURLState('source_line');
+ const [_unusedLine, setSourceLine] = useQueryState('source_line', stringParam);
const openFile = (): void => {
- setDashboardItems([dashboardItems[0], 'source']);
+ void setDashboardItems([dashboardItems[0], 'source']);
if (mappingBuildID != null) {
- setSourceBuildId(mappingBuildID);
+ void setSourceBuildId(mappingBuildID);
}
- setSourceFilename(functionFilename);
+ void setSourceFilename(functionFilename);
if (lineNumber !== undefined) {
- setSourceLine(lineNumber.toString());
+ void setSourceLine(lineNumber.toString());
}
};
diff --git a/ui/packages/shared/profile/src/ProfileExplorer/ProfileExplorerCompare.tsx b/ui/packages/shared/profile/src/ProfileExplorer/ProfileExplorerCompare.tsx
index 067661954a2..d8f9a9c6958 100644
--- a/ui/packages/shared/profile/src/ProfileExplorer/ProfileExplorerCompare.tsx
+++ b/ui/packages/shared/profile/src/ProfileExplorer/ProfileExplorerCompare.tsx
@@ -14,7 +14,6 @@
import {useCallback, useEffect, useMemo, useState} from 'react';
import {QueryServiceClient} from '@parca/client';
-import {useURLStateBatch} from '@parca/components';
import {Query} from '@parca/parser';
import {TEST_IDS, testId} from '@parca/test-utils';
import type {NavigateFunction} from '@parca/utilities';
@@ -34,7 +33,6 @@ const ProfileExplorerCompare = ({
navigateTo,
}: ProfileExplorerCompareProps): JSX.Element => {
const [showMetricsGraph, setShowMetricsGraph] = useState(true);
- const batchUpdates = useURLStateBatch();
const {closeCompareMode, isCompareMode, isCompareAbsolute} = useCompareModeMeta();
// Read ProfileSource states from URL for both sides
@@ -65,12 +63,10 @@ const ProfileExplorerCompare = ({
}
if (querySelectionB.expression === '' && querySelectionA.expression !== '') {
- batchUpdates(() => {
- setDraftExpressionB(querySelectionA.expression);
- setDraftTimeRangeB(querySelectionA.from, querySelectionA.to, querySelectionA.timeSelection);
- // Commit to update the URL and trigger metrics graph load
- commitDraftB();
- });
+ setDraftExpressionB(querySelectionA.expression);
+ setDraftTimeRangeB(querySelectionA.from, querySelectionA.to, querySelectionA.timeSelection);
+ // Commit to update the URL and trigger metrics graph load
+ commitDraftB();
}
}, [
isCompareMode,
@@ -82,7 +78,6 @@ const ProfileExplorerCompare = ({
setDraftExpressionB,
setDraftTimeRangeB,
commitDraftB,
- batchUpdates,
]);
const closeProfileA = useCallback((): void => {
diff --git a/ui/packages/shared/profile/src/ProfileFlameChart/SamplesStrips/index.tsx b/ui/packages/shared/profile/src/ProfileFlameChart/SamplesStrips/index.tsx
index 8468c1b8971..c2cbf74e3ab 100644
--- a/ui/packages/shared/profile/src/ProfileFlameChart/SamplesStrips/index.tsx
+++ b/ui/packages/shared/profile/src/ProfileFlameChart/SamplesStrips/index.tsx
@@ -39,10 +39,10 @@ interface Props {
loading?: boolean;
cpus: LabelSet[];
data: DataPoint[][];
- selectedTimeframe?: {
+ selectedTimeframe: {
labels: LabelSet;
bounds: NumberDuo;
- };
+ } | null;
onSelectedTimeframe: (labels: LabelSet, bounds: NumberDuo | undefined) => void;
width?: number;
bounds: NumberDuo;
diff --git a/ui/packages/shared/profile/src/ProfileFlameChart/index.tsx b/ui/packages/shared/profile/src/ProfileFlameChart/index.tsx
index c46f60fd2ce..4a3f417c636 100644
--- a/ui/packages/shared/profile/src/ProfileFlameChart/index.tsx
+++ b/ui/packages/shared/profile/src/ProfileFlameChart/index.tsx
@@ -13,8 +13,9 @@
import {useEffect, useMemo, useRef} from 'react';
+import {createParser, useQueryState} from 'nuqs';
+
import {LabelSet, QueryRequest_ReportType, QueryServiceClient} from '@parca/client';
-import {useURLState, useURLStateCustom, type OptionsCustom} from '@parca/components';
import {Matcher, MatcherTypes, ProfileType, Query} from '@parca/parser';
import {TimeUnits, formatDate, formatDuration} from '@parca/utilities';
@@ -22,6 +23,7 @@ import ProfileFlameGraph, {validateFlameChartQuery} from '../ProfileFlameGraph';
import {boundsFromProfileSource} from '../ProfileFlameGraph/FlameGraphArrow/utils';
import {MergedProfileSource, ProfileSource, timeFormat} from '../ProfileSource';
import type {SamplesData} from '../ProfileView/types/visualization';
+import {flamechartDimensionParser} from '../hooks/urlParsers';
import {useQuery} from '../useQuery';
import {NumberDuo} from '../utils';
import {SamplesStrip} from './SamplesStrips';
@@ -31,11 +33,8 @@ interface SelectedTimeframe {
bounds: NumberDuo;
}
-const TimeframeStateSerializer: OptionsCustom = {
- parse: (value: string | string[] | undefined) => {
- if (value == null || value === '' || value === 'undefined' || Array.isArray(value)) {
- return undefined;
- }
+const timeframeParser = createParser({
+ parse: (value: string) => {
try {
const [labelPart, boundsPart] = value.split('|');
if (labelPart != null && boundsPart != null) {
@@ -54,16 +53,13 @@ const TimeframeStateSerializer: OptionsCustom = {
} catch {
// Ignore parsing errors
}
- return undefined;
+ return null;
},
- stringify: (value: SelectedTimeframe | undefined) => {
- if (value == null) {
- return '';
- }
+ serialize: (value: SelectedTimeframe) => {
const labelsStr = value.labels.labels.map(l => `${l.name}:${l.value}`).join(',');
return `${labelsStr}|${value.bounds[0]},${value.bounds[1]}`;
},
-};
+}).withOptions({history: 'replace'});
interface ProfileFlameChartProps {
samplesData?: SamplesData;
@@ -121,14 +117,16 @@ export const ProfileFlameChart = ({
}: ProfileFlameChartProps): JSX.Element => {
const zoomControlsRef = useRef(null);
- const [selectedTimeframe, setSelectedTimeframe] = useURLStateCustom<
- SelectedTimeframe | undefined
- >('flamechart_timeframe', TimeframeStateSerializer);
+ const [selectedTimeframe, setSelectedTimeframe] = useQueryState(
+ 'flamechart_timeframe',
+ timeframeParser
+ );
// Read flamechart dimension from URL state to detect changes
- const [flamechartDimension] = useURLState('flamechart_dimension', {
- alwaysReturnArray: true,
- });
+ const [flamechartDimension] = useQueryState(
+ 'flamechart_dimension',
+ flamechartDimensionParser.withDefault([])
+ );
// Reset selection when the parent time range (profileSource) changes
const timeBoundsKey = boundsFromProfileSource(profileSource).join(',');
@@ -136,7 +134,7 @@ export const ProfileFlameChart = ({
useEffect(() => {
if (prevTimeBoundsKey.current !== timeBoundsKey) {
prevTimeBoundsKey.current = timeBoundsKey;
- setSelectedTimeframe(undefined);
+ void setSelectedTimeframe(null);
}
}, [timeBoundsKey, setSelectedTimeframe]);
@@ -146,16 +144,16 @@ export const ProfileFlameChart = ({
useEffect(() => {
if (prevDimensionKey.current !== dimensionKey) {
prevDimensionKey.current = dimensionKey;
- setSelectedTimeframe(undefined);
+ void setSelectedTimeframe(null);
}
}, [dimensionKey, setSelectedTimeframe]);
// Handle timeframe selection from strips
const handleSelectedTimeframe = (labels: LabelSet, bounds: NumberDuo | undefined): void => {
if (bounds === undefined) {
- setSelectedTimeframe(undefined);
+ void setSelectedTimeframe(null);
} else {
- setSelectedTimeframe({labels, bounds});
+ void setSelectedTimeframe({labels, bounds});
}
};
diff --git a/ui/packages/shared/profile/src/ProfileFlameGraph/FlameGraphArrow/ContextMenu.tsx b/ui/packages/shared/profile/src/ProfileFlameGraph/FlameGraphArrow/ContextMenu.tsx
index ac33f8e838c..3ffed51c2c6 100644
--- a/ui/packages/shared/profile/src/ProfileFlameGraph/FlameGraphArrow/ContextMenu.tsx
+++ b/ui/packages/shared/profile/src/ProfileFlameGraph/FlameGraphArrow/ContextMenu.tsx
@@ -14,10 +14,11 @@
import {Icon} from '@iconify/react';
import {Table} from '@uwdata/flechette';
import cx from 'classnames';
+import {useQueryState} from 'nuqs';
import {Item, Menu, Separator, Submenu} from 'react-contexify';
import {Tooltip} from 'react-tooltip';
-import {useParcaContext, useURLState} from '@parca/components';
+import {useParcaContext} from '@parca/components';
import {USER_PREFERENCES, useUserPreference} from '@parca/hooks';
import {ProfileType} from '@parca/parser';
import {TEST_IDS} from '@parca/test-utils';
@@ -25,6 +26,7 @@ import {getLastItem} from '@parca/utilities';
import {useGraphTooltip} from '../../GraphTooltipArrow/useGraphTooltip';
import {useGraphTooltipMetaInfo} from '../../GraphTooltipArrow/useGraphTooltipMetaInfo';
+import {dashboardItemsParser, stringParam} from '../../hooks/urlParsers';
import {hexifyAddress, truncateString} from '../../utils';
interface ContextMenuProps {
@@ -83,12 +85,13 @@ const ContextMenu = ({
inlined,
} = useGraphTooltipMetaInfo({table, row});
- const [dashboardItems, setDashboardItems] = useURLState('dashboard_items', {
- alwaysReturnArray: true,
- });
- // eslint-disable-next-line @typescript-eslint/no-unused-vars
- const [sandwichFunctionName, setSandwichFunctionName] = useURLState(
- 'sandwich_function_name'
+ const [dashboardItems, setDashboardItems] = useQueryState(
+ 'dashboard_items',
+ dashboardItemsParser
+ );
+ const [_sandwichFunctionName, setSandwichFunctionName] = useQueryState(
+ 'sandwich_function_name',
+ stringParam
);
if (contextMenuData === null) {
@@ -174,9 +177,9 @@ const ContextMenu = ({
id="show-in-table"
onClick={() => {
if (isSandwich) {
- setDashboardItems(['table']);
+ void setDashboardItems(['table']);
} else {
- setDashboardItems([...dashboardItems, 'table']);
+ void setDashboardItems([...dashboardItems, 'table']);
}
}}
>
@@ -195,13 +198,13 @@ const ContextMenu = ({
}
if (dashboardItems.includes('sandwich')) {
- setSandwichFunctionName(functionName);
+ void setSandwichFunctionName(functionName);
hideMenu();
return;
}
- setSandwichFunctionName(functionName);
- setDashboardItems([...dashboardItems, 'sandwich']);
+ void setSandwichFunctionName(functionName);
+ void setDashboardItems([...dashboardItems, 'sandwich']);
hideMenu();
}}
disabled={functionName === '' || functionName == null}
diff --git a/ui/packages/shared/profile/src/ProfileFlameGraph/FlameGraphArrow/TextWithEllipsis.tsx b/ui/packages/shared/profile/src/ProfileFlameGraph/FlameGraphArrow/TextWithEllipsis.tsx
index d020ace0ac7..c98d66982b9 100644
--- a/ui/packages/shared/profile/src/ProfileFlameGraph/FlameGraphArrow/TextWithEllipsis.tsx
+++ b/ui/packages/shared/profile/src/ProfileFlameGraph/FlameGraphArrow/TextWithEllipsis.tsx
@@ -13,7 +13,9 @@
import {useEffect, useRef, useState} from 'react';
-import {useURLState} from '@parca/components';
+import {useQueryState} from 'nuqs';
+
+import {stringParam} from '../../hooks/urlParsers';
interface Props {
text: string;
@@ -66,9 +68,9 @@ function calculateTruncatedText(
function TextWithEllipsis({text, x, y, width}: Props): JSX.Element {
const textRef = useRef(null);
const [displayText, setDisplayText] = useState(text);
- const [alignFunctionName] = useURLState('align_function_name');
+ const [alignFunctionName] = useQueryState('align_function_name', stringParam.withDefault('left'));
- const showFunctionNameFromLeft = alignFunctionName === 'left' || alignFunctionName === undefined;
+ const showFunctionNameFromLeft = alignFunctionName === 'left';
useEffect(() => {
const textElement = textRef.current;
diff --git a/ui/packages/shared/profile/src/ProfileFlameGraph/index.tsx b/ui/packages/shared/profile/src/ProfileFlameGraph/index.tsx
index d77cd2aadcc..4ede865ba14 100644
--- a/ui/packages/shared/profile/src/ProfileFlameGraph/index.tsx
+++ b/ui/packages/shared/profile/src/ProfileFlameGraph/index.tsx
@@ -15,15 +15,11 @@ import React, {LegacyRef, ReactNode, useCallback, useEffect, useMemo, useState}
import cx from 'classnames';
import {AnimatePresence, motion} from 'framer-motion';
+import {useQueryState} from 'nuqs';
import {useMeasure} from 'react-use';
import {FlamegraphArrow} from '@parca/client';
-import {
- FlameGraphSkeleton,
- SandwichFlameGraphSkeleton,
- useParcaContext,
- useURLState,
-} from '@parca/components';
+import {FlameGraphSkeleton, SandwichFlameGraphSkeleton, useParcaContext} from '@parca/components';
import {ProfileType} from '@parca/parser';
import {TEST_IDS, testId} from '@parca/test-utils';
import {capitalizeOnlyFirstLetter, divide} from '@parca/utilities';
@@ -33,6 +29,7 @@ import DiffLegend from '../ProfileView/components/DiffLegend';
import {useProfileViewContext} from '../ProfileView/context/ProfileViewContext';
import {useProfileMetadata} from '../ProfileView/hooks/useProfileMetadata';
import {useVisualizationState} from '../ProfileView/hooks/useVisualizationState';
+import {boolParam} from '../hooks/urlParsers';
import {FlameGraphArrow} from './FlameGraphArrow';
import {CurrentPathFrame} from './FlameGraphArrow/utils';
@@ -135,8 +132,8 @@ const ProfileFlameGraph = function ProfileFlameGraphNonMemo({
// For non-delta profiles, like goroutines or memory, we want the profiles to be compared absolutely.
const compareAbsoluteDefault = profileType?.delta === false ? 'true' : 'false';
- const [compareAbsolute = compareAbsoluteDefault] = useURLState('compare_absolute');
- const isCompareAbsolute = compareAbsolute === 'true';
+ const [compareAbsolute] = useQueryState('compare_absolute', boolParam);
+ const isCompareAbsolute = compareAbsolute ?? compareAbsoluteDefault === 'true';
const mappingsListCount = useMemo(
() => mappingsList.filter(m => m !== '').length,
@@ -178,7 +175,7 @@ const ProfileFlameGraph = function ProfileFlameGraphNonMemo({
// If there is only one mapping file, we want to color by filename by default.
useEffect(() => {
if (mappingsListCount === 1 && colorBy !== 'filename') {
- setColorBy('filename');
+ void setColorBy('filename');
}
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [mappingsListCount]);
diff --git a/ui/packages/shared/profile/src/ProfileMetricsGraph/index.tsx b/ui/packages/shared/profile/src/ProfileMetricsGraph/index.tsx
index fb64a5a5605..030ec073056 100644
--- a/ui/packages/shared/profile/src/ProfileMetricsGraph/index.tsx
+++ b/ui/packages/shared/profile/src/ProfileMetricsGraph/index.tsx
@@ -15,6 +15,7 @@ import {useEffect, useMemo, useState} from 'react';
import {Icon} from '@iconify/react';
import {AnimatePresence, motion} from 'framer-motion';
+import {useQueryState} from 'nuqs';
import {
Label,
@@ -25,11 +26,8 @@ import {
import {
DateTimeRange,
MetricsGraphSkeleton,
- NumberParser,
- NumberSerializer,
TextWithTooltip,
useParcaContext,
- useURLStateCustom,
} from '@parca/components';
import {Query} from '@parca/parser';
import {TEST_IDS, testId} from '@parca/test-utils';
@@ -38,6 +36,7 @@ import {capitalizeOnlyFirstLetter, formatDate, timePattern, valueFormatter} from
import {MergedProfileSelection, ProfileSelection} from '..';
import MetricsGraph, {ContextMenuItemOrSubmenu, Series, SeriesPoint} from '../MetricsGraph';
import {useMetricsGraphDimensions} from '../MetricsGraph/useMetricsGraphDimensions';
+import {intParam} from '../hooks/urlParsers';
import {getStepCountFromScreenWidth, useQueryRange} from './hooks/useQueryRange';
const createProfileContextMenuItems = (
@@ -200,11 +199,10 @@ const ProfileMetricsGraph = ({
comparing = false,
sumBy,
}: ProfileMetricsGraphProps): JSX.Element => {
- const [rawStepCount] = useURLStateCustom('step_count', {
- defaultValue: String(getStepCountFromScreenWidth(10)),
- parse: NumberParser,
- stringify: NumberSerializer,
- });
+ const [rawStepCount] = useQueryState(
+ 'step_count',
+ intParam.withDefault(getStepCountFromScreenWidth(10))
+ );
// Clamp step count so the step duration is at least 1 second as we don't have this enforced server-side anymore.
const stepCount = useMemo(() => {
const maxForOneSecond = Math.floor((to - from) / 1000);
diff --git a/ui/packages/shared/profile/src/ProfileSelector/MetricsGraphSection.tsx b/ui/packages/shared/profile/src/ProfileSelector/MetricsGraphSection.tsx
index 57503e5ed9b..a09228fbc1b 100644
--- a/ui/packages/shared/profile/src/ProfileSelector/MetricsGraphSection.tsx
+++ b/ui/packages/shared/profile/src/ProfileSelector/MetricsGraphSection.tsx
@@ -14,7 +14,7 @@
import cx from 'classnames';
import {Label, QueryServiceClient} from '@parca/client';
-import {DateTimeRange, useParcaContext, useURLStateBatch} from '@parca/components';
+import {DateTimeRange, useParcaContext} from '@parca/components';
import {Query} from '@parca/parser';
import {ProfileSelection} from '..';
@@ -67,7 +67,6 @@ export function MetricsGraphSection({
hasNoProfileTypes = false,
}: MetricsGraphSectionProps): JSX.Element {
const resetStateOnSeriesChange = useResetStateOnSeriesChange();
- const batchUpdates = useURLStateBatch();
const {profileExplorer} = useParcaContext();
const {heightStyle} = useMetricsGraphDimensions(comparing, profileExplorer?.metricsGraph.height);
const handleTimeRangeChange = (range: DateTimeRange): void => {
@@ -117,10 +116,8 @@ export function MetricsGraphSection({
if (hasChanged) {
// Immediately apply the filter when adding label matchers from the graph
- batchUpdates(() => {
- setNewQueryExpression(newQuery.toString());
- commitDraft(undefined, newQuery.toString());
- });
+ setNewQueryExpression(newQuery.toString());
+ commitDraft(undefined, newQuery.toString());
}
};
@@ -141,10 +138,8 @@ export function MetricsGraphSection({
const mergeFrom = timestamp;
const mergeTo = query.profileType().delta ? mergeFrom + BigInt(duration) : mergeFrom;
- batchUpdates(() => {
- resetStateOnSeriesChange(); // reset some state when a new series is selected
- setProfileSelection(mergeFrom, mergeTo, query);
- });
+ resetStateOnSeriesChange(); // reset some state when a new series is selected
+ setProfileSelection(mergeFrom, mergeTo, query);
};
return (
diff --git a/ui/packages/shared/profile/src/ProfileSelector/index.tsx b/ui/packages/shared/profile/src/ProfileSelector/index.tsx
index e44d2185c5f..276ef2fcdb2 100644
--- a/ui/packages/shared/profile/src/ProfileSelector/index.tsx
+++ b/ui/packages/shared/profile/src/ProfileSelector/index.tsx
@@ -14,16 +14,10 @@
import {Dispatch, SetStateAction, useCallback, useEffect, useMemo, useRef, useState} from 'react';
import {RpcError} from '@protobuf-ts/runtime-rpc';
+import {useQueryState as useNuqsQueryState} from 'nuqs';
import {ProfileTypesRequest, ProfileTypesResponse, QueryServiceClient} from '@parca/client';
-import {
- DateTimeRange,
- IconButton,
- useGrpcMetadata,
- useParcaContext,
- useURLState,
- useURLStateBatch,
-} from '@parca/components';
+import {DateTimeRange, IconButton, useGrpcMetadata, useParcaContext} from '@parca/components';
import {CloseIcon} from '@parca/icons';
import {Query} from '@parca/parser';
import {TEST_IDS, testId} from '@parca/test-utils';
@@ -36,6 +30,7 @@ import {
import {QueryControls} from '../QueryControls';
import {LabelsQueryProvider, useLabelsQueryProvider} from '../contexts/LabelsQueryProvider';
import {UnifiedLabelsProvider} from '../contexts/UnifiedLabelsContext';
+import {stringParam} from '../hooks/urlParsers';
import {useLabelNames} from '../hooks/useLabels';
import {useQueryState} from '../hooks/useQueryState';
import useGrpcQuery from '../useGrpcQuery';
@@ -118,8 +113,10 @@ const ProfileSelector = ({
onSearchHook,
}: ProfileSelectorProps): JSX.Element => {
const {externalProfilerComponent, additionalMetricsGraph} = useParcaContext();
- const [queryBrowserMode, setQueryBrowserMode] = useURLState('query_browser_mode');
- const batchUpdates = useURLStateBatch();
+ const [queryBrowserMode, setQueryBrowserMode] = useNuqsQueryState(
+ 'query_browser_mode',
+ stringParam
+ );
const profileFilterDefaults = externalProfilerComponent?.profileFilterDefaults as
| ProfileFilter[]
@@ -222,27 +219,25 @@ const ProfileSelector = ({
const selectedProfileName = query.profileName();
const setQueryExpression = (updateTs = false): void => {
- batchUpdates(() => {
- if (onSearchHook != null) {
- onSearchHook();
- }
- // When updateTs is true, re-evaluate the time range to current values
- if (updateTs) {
- // Force re-evaluation of time range (important for relative ranges like "last 15 minutes")
- const currentFrom = timeRangeSelection.getFromMs(true);
- const currentTo = timeRangeSelection.getToMs(true);
- const currentRangeKey = timeRangeSelection.getRangeKey();
- // Commit with refreshed time range
- commitDraft({
- from: currentFrom,
- to: currentTo,
- timeSelection: currentRangeKey,
- });
- } else {
- // Commit the draft with existing values
- commitDraft();
- }
- });
+ if (onSearchHook != null) {
+ onSearchHook();
+ }
+ // When updateTs is true, re-evaluate the time range to current values
+ if (updateTs) {
+ // Force re-evaluation of time range (important for relative ranges like "last 15 minutes")
+ const currentFrom = timeRangeSelection.getFromMs(true);
+ const currentTo = timeRangeSelection.getToMs(true);
+ const currentRangeKey = timeRangeSelection.getRangeKey();
+ // Commit with refreshed time range
+ commitDraft({
+ from: currentFrom,
+ to: currentTo,
+ timeSelection: currentRangeKey,
+ });
+ } else {
+ // Commit the draft with existing values
+ commitDraft();
+ }
};
const setMatchersString = (matchers: string): void => {
diff --git a/ui/packages/shared/profile/src/ProfileView/components/ActionButtons/SortByDropdown.tsx b/ui/packages/shared/profile/src/ProfileView/components/ActionButtons/SortByDropdown.tsx
index 17201575cd2..3c9cc14c3dc 100644
--- a/ui/packages/shared/profile/src/ProfileView/components/ActionButtons/SortByDropdown.tsx
+++ b/ui/packages/shared/profile/src/ProfileView/components/ActionButtons/SortByDropdown.tsx
@@ -11,19 +11,23 @@
// See the License for the specific language governing permissions and
// limitations under the License.
-import {Select, useURLState} from '@parca/components';
+import {useQueryState} from 'nuqs';
+
+import {Select} from '@parca/components';
import {
FIELD_CUMULATIVE,
FIELD_DIFF,
FIELD_FUNCTION_NAME,
} from '../../../ProfileFlameGraph/FlameGraphArrow';
+import {stringParam} from '../../../hooks/urlParsers';
import {useProfileViewContext} from '../../context/ProfileViewContext';
const SortByDropdown = (): React.JSX.Element => {
- const [storeSortBy, setStoreSortBy] = useURLState('sort_by', {
- defaultValue: FIELD_FUNCTION_NAME,
- });
+ const [storeSortBy, setStoreSortBy] = useQueryState(
+ 'sort_by',
+ stringParam.withDefault(FIELD_FUNCTION_NAME)
+ );
const {compareMode} = useProfileViewContext();
@@ -70,8 +74,8 @@ const SortByDropdown = (): React.JSX.Element => {
},
},
]}
- selectedKey={storeSortBy as string}
- onSelection={key => setStoreSortBy(key)}
+ selectedKey={storeSortBy}
+ onSelection={key => void setStoreSortBy(key)}
placeholder={'Sort By'}
primary={false}
disabled={false}
diff --git a/ui/packages/shared/profile/src/ProfileView/components/ColorStackLegend.tsx b/ui/packages/shared/profile/src/ProfileView/components/ColorStackLegend.tsx
index e64b3f6a6e2..64e6f28f24b 100644
--- a/ui/packages/shared/profile/src/ProfileView/components/ColorStackLegend.tsx
+++ b/ui/packages/shared/profile/src/ProfileView/components/ColorStackLegend.tsx
@@ -15,13 +15,14 @@ import React, {useMemo} from 'react';
import {Icon} from '@iconify/react';
import cx from 'classnames';
+import {useQueryState} from 'nuqs';
-import {useURLState} from '@parca/components';
import {USER_PREFERENCES, useCurrentColorProfile, useUserPreference} from '@parca/hooks';
import {EVERYTHING_ELSE, selectDarkMode, useAppSelector} from '@parca/store';
import {getMappingColors} from '../../ProfileFlameGraph/FlameGraphArrow';
import useMappingList from '../../ProfileFlameGraph/FlameGraphArrow/useMappingList';
+import {colorByParser} from '../../hooks/urlParsers';
import {useProfileFilters} from './ProfileFilters/useProfileFilters';
interface Props {
@@ -37,9 +38,7 @@ const ColorStackLegend = ({mappings, compareMode = false, loading}: Props): Reac
USER_PREFERENCES.FLAMEGRAPH_COLOR_PROFILE.key
);
- const [colorByValue, _] = useURLState('color_by');
-
- const colorBy = colorByValue === 'binary' || colorByValue === undefined ? 'binary' : 'filename';
+ const [colorBy] = useQueryState('color_by', colorByParser);
const {appliedFilters, removeExcludeBinary, excludeBinary} = useProfileFilters();
diff --git a/ui/packages/shared/profile/src/ProfileView/components/InvertCallStack/index.tsx b/ui/packages/shared/profile/src/ProfileView/components/InvertCallStack/index.tsx
index bbc1c20387b..40207a93f1c 100644
--- a/ui/packages/shared/profile/src/ProfileView/components/InvertCallStack/index.tsx
+++ b/ui/packages/shared/profile/src/ProfileView/components/InvertCallStack/index.tsx
@@ -12,19 +12,20 @@
// limitations under the License.
import {Icon} from '@iconify/react';
+import {useQueryState} from 'nuqs';
-import {Button, useURLState} from '@parca/components';
+import {Button} from '@parca/components';
import {TEST_IDS, testId} from '@parca/test-utils';
+import {invertCallStackParser} from '../../../hooks/urlParsers';
import {useResetFlameGraphState} from '../../hooks/useResetFlameGraphState';
const InvertCallStack = (): JSX.Element => {
- const [invertStack = '', setInvertStack] = useURLState('invert_call_stack');
- const isInvert = invertStack === 'true';
+ const [isInvert, setInvertStack] = useQueryState('invert_call_stack', invertCallStackParser);
const resetFlameGraphState = useResetFlameGraphState();
const handleSetInvert = (value: boolean): void => {
- setInvertStack(value ? 'true' : '');
+ void setInvertStack(value);
resetFlameGraphState();
};
diff --git a/ui/packages/shared/profile/src/ProfileView/components/ProfileFilters/useProfileFiltersUrlState.test.tsx b/ui/packages/shared/profile/src/ProfileView/components/ProfileFilters/useProfileFiltersUrlState.test.tsx
index 0efe130c369..f991534ce7b 100644
--- a/ui/packages/shared/profile/src/ProfileView/components/ProfileFilters/useProfileFiltersUrlState.test.tsx
+++ b/ui/packages/shared/profile/src/ProfileView/components/ProfileFilters/useProfileFiltersUrlState.test.tsx
@@ -15,78 +15,27 @@ import {type ReactNode} from 'react';
// eslint-disable-next-line import/named
import {act, renderHook, waitFor} from '@testing-library/react';
+import {NuqsTestingAdapter, type OnUrlUpdateFunction} from 'nuqs/adapters/testing';
import {beforeEach, describe, expect, it, vi} from 'vitest';
-import {URLStateProvider} from '@parca/components';
-
import {type ProfileFilter} from './useProfileFilters';
import {decodeProfileFilters, useProfileFiltersUrlState} from './useProfileFiltersUrlState';
-// Mock window.location
-const mockLocation = {
- pathname: '/test',
- search: '',
-};
-
-// Mock the navigate function
-const mockNavigateTo = vi.fn((path: string, params: Record) => {
- const searchParams = new URLSearchParams();
- Object.entries(params).forEach(([key, value]) => {
- if (value !== undefined && value !== null) {
- if (Array.isArray(value)) {
- searchParams.set(key, value.join(','));
- } else {
- searchParams.set(key, String(value));
- }
- }
- });
- mockLocation.search = `?${searchParams.toString()}`;
-});
-
-// Mock getQueryParamsFromURL
-vi.mock('@parca/components/src/hooks/URLState/utils', async () => {
- const actual = await vi.importActual('@parca/components/src/hooks/URLState/utils');
- return {
- ...actual,
- getQueryParamsFromURL: () => {
- if (mockLocation.search === '') return {};
- const params = new URLSearchParams(mockLocation.search);
- const result: Record = {};
- for (const [key, value] of params.entries()) {
- const decodedValue = decodeURIComponent(value);
- const existing = result[key];
- if (existing !== undefined) {
- result[key] = Array.isArray(existing)
- ? [...existing, decodedValue]
- : [existing, decodedValue];
- } else {
- result[key] = decodedValue;
- }
- }
- return result;
- },
- };
-});
-
-// Helper to create wrapper with URLStateProvider
-const createWrapper = (): (({children}: {children: ReactNode}) => JSX.Element) => {
+// Helper to create wrapper with NuqsTestingAdapter
+const createWrapper = (
+ searchParams: string | Record = {},
+ onUrlUpdate?: OnUrlUpdateFunction
+): (({children}: {children: ReactNode}) => JSX.Element) => {
const Wrapper = ({children}: {children: ReactNode}): JSX.Element => (
- {children}
+
+ {children}
+
);
- Wrapper.displayName = 'URLStateProviderWrapper';
+ Wrapper.displayName = 'NuqsTestingWrapper';
return Wrapper;
};
describe('useProfileFiltersUrlState', () => {
- beforeEach(() => {
- mockNavigateTo.mockClear();
- Object.defineProperty(window, 'location', {
- value: mockLocation,
- writable: true,
- });
- mockLocation.search = '';
- });
-
describe('decodeProfileFilters', () => {
it('should return empty array for empty string', () => {
expect(decodeProfileFilters('')).toEqual([]);
@@ -255,9 +204,9 @@ describe('useProfileFiltersUrlState', () => {
});
it('should read filters from URL', async () => {
- mockLocation.search = '?profile_filters=s:fn:=:testFunc';
-
- const {result} = renderHook(() => useProfileFiltersUrlState(), {wrapper: createWrapper()});
+ const {result} = renderHook(() => useProfileFiltersUrlState(), {
+ wrapper: createWrapper({profile_filters: 's:fn:=:testFunc'}),
+ });
await waitFor(() => {
expect(result.current.appliedFilters).toHaveLength(1);
@@ -271,7 +220,10 @@ describe('useProfileFiltersUrlState', () => {
});
it('should update URL when setting filters', async () => {
- const {result} = renderHook(() => useProfileFiltersUrlState(), {wrapper: createWrapper()});
+ const onUrlUpdate = vi.fn();
+ const {result} = renderHook(() => useProfileFiltersUrlState(), {
+ wrapper: createWrapper({}, onUrlUpdate),
+ });
const newFilters: ProfileFilter[] = [
{
@@ -288,26 +240,26 @@ describe('useProfileFiltersUrlState', () => {
});
await waitFor(() => {
- expect(mockNavigateTo).toHaveBeenCalled();
- const [, params] = mockNavigateTo.mock.calls[mockNavigateTo.mock.calls.length - 1];
- expect(params.profile_filters).toBe('f:b:!~:libc.so');
+ expect(onUrlUpdate).toHaveBeenCalled();
+ const lastCall = onUrlUpdate.mock.calls[onUrlUpdate.mock.calls.length - 1][0];
+ expect(lastCall.searchParams.get('profile_filters')).toBe('f:b:!~:libc.so');
});
});
it('should clear URL param when setting empty filters', async () => {
- mockLocation.search = '?profile_filters=s:fn:=:testFunc';
-
- const {result} = renderHook(() => useProfileFiltersUrlState(), {wrapper: createWrapper()});
+ const onUrlUpdate = vi.fn();
+ const {result} = renderHook(() => useProfileFiltersUrlState(), {
+ wrapper: createWrapper({profile_filters: 's:fn:=:testFunc'}, onUrlUpdate),
+ });
act(() => {
result.current.setAppliedFilters([]);
});
await waitFor(() => {
- expect(mockNavigateTo).toHaveBeenCalled();
- const [, params] = mockNavigateTo.mock.calls[mockNavigateTo.mock.calls.length - 1];
- // When filters are empty, the param is either empty string or undefined (removed)
- expect(params.profile_filters === '' || params.profile_filters === undefined).toBe(true);
+ expect(onUrlUpdate).toHaveBeenCalled();
+ const lastCall = onUrlUpdate.mock.calls[onUrlUpdate.mock.calls.length - 1][0];
+ expect(lastCall.searchParams.has('profile_filters')).toBe(false);
});
});
});
@@ -320,9 +272,10 @@ describe('useProfileFiltersUrlState', () => {
});
it('should force apply filters overwriting existing', async () => {
- mockLocation.search = '?profile_filters=s:fn:=:existingFunc';
-
- const {result} = renderHook(() => useProfileFiltersUrlState(), {wrapper: createWrapper()});
+ const onUrlUpdate = vi.fn();
+ const {result} = renderHook(() => useProfileFiltersUrlState(), {
+ wrapper: createWrapper({profile_filters: 's:fn:=:existingFunc'}, onUrlUpdate),
+ });
// Verify existing filter is loaded
await waitFor(() => {
@@ -344,33 +297,36 @@ describe('useProfileFiltersUrlState', () => {
});
await waitFor(() => {
- expect(mockNavigateTo).toHaveBeenCalled();
- const [, params] = mockNavigateTo.mock.calls[mockNavigateTo.mock.calls.length - 1];
- expect(params.profile_filters).toBe('f:b:!~:forcedValue');
+ expect(onUrlUpdate).toHaveBeenCalled();
+ const lastCall = onUrlUpdate.mock.calls[onUrlUpdate.mock.calls.length - 1][0];
+ expect(lastCall.searchParams.get('profile_filters')).toBe('f:b:!~:forcedValue');
});
});
it('should clear filters when force applying empty array', async () => {
- mockLocation.search = '?profile_filters=s:fn:=:existingFunc';
-
- const {result} = renderHook(() => useProfileFiltersUrlState(), {wrapper: createWrapper()});
+ const onUrlUpdate = vi.fn();
+ const {result} = renderHook(() => useProfileFiltersUrlState(), {
+ wrapper: createWrapper({profile_filters: 's:fn:=:existingFunc'}, onUrlUpdate),
+ });
act(() => {
result.current.forceApplyFilters([]);
});
await waitFor(() => {
- expect(mockNavigateTo).toHaveBeenCalled();
- const [, params] = mockNavigateTo.mock.calls[mockNavigateTo.mock.calls.length - 1];
- // When filters are empty, the param is either empty string or undefined (removed)
- expect(params.profile_filters === '' || params.profile_filters === undefined).toBe(true);
+ expect(onUrlUpdate).toHaveBeenCalled();
+ const lastCall = onUrlUpdate.mock.calls[onUrlUpdate.mock.calls.length - 1][0];
+ expect(lastCall.searchParams.has('profile_filters')).toBe(false);
});
});
});
describe('Preset filter encoding', () => {
it('should encode preset filters correctly', async () => {
- const {result} = renderHook(() => useProfileFiltersUrlState(), {wrapper: createWrapper()});
+ const onUrlUpdate = vi.fn();
+ const {result} = renderHook(() => useProfileFiltersUrlState(), {
+ wrapper: createWrapper({}, onUrlUpdate),
+ });
const presetFilters: ProfileFilter[] = [
{
@@ -385,14 +341,17 @@ describe('useProfileFiltersUrlState', () => {
});
await waitFor(() => {
- expect(mockNavigateTo).toHaveBeenCalled();
- const [, params] = mockNavigateTo.mock.calls[mockNavigateTo.mock.calls.length - 1];
- expect(params.profile_filters).toBe('p:hide_libc:enabled');
+ expect(onUrlUpdate).toHaveBeenCalled();
+ const lastCall = onUrlUpdate.mock.calls[onUrlUpdate.mock.calls.length - 1][0];
+ expect(lastCall.searchParams.get('profile_filters')).toBe('p:hide_libc:enabled');
});
});
it('should handle mixed preset and regular filters', async () => {
- const {result} = renderHook(() => useProfileFiltersUrlState(), {wrapper: createWrapper()});
+ const onUrlUpdate = vi.fn();
+ const {result} = renderHook(() => useProfileFiltersUrlState(), {
+ wrapper: createWrapper({}, onUrlUpdate),
+ });
const mixedFilters: ProfileFilter[] = [
{
@@ -414,16 +373,21 @@ describe('useProfileFiltersUrlState', () => {
});
await waitFor(() => {
- expect(mockNavigateTo).toHaveBeenCalled();
- const [, params] = mockNavigateTo.mock.calls[mockNavigateTo.mock.calls.length - 1];
- expect(params.profile_filters).toBe('p:hide_libc:enabled,f:b:!~:node');
+ expect(onUrlUpdate).toHaveBeenCalled();
+ const lastCall = onUrlUpdate.mock.calls[onUrlUpdate.mock.calls.length - 1][0];
+ expect(lastCall.searchParams.get('profile_filters')).toBe(
+ 'p:hide_libc:enabled,f:b:!~:node'
+ );
});
});
});
describe('URL encoding edge cases', () => {
it('should handle special characters in filter values', async () => {
- const {result} = renderHook(() => useProfileFiltersUrlState(), {wrapper: createWrapper()});
+ const onUrlUpdate = vi.fn();
+ const {result} = renderHook(() => useProfileFiltersUrlState(), {
+ wrapper: createWrapper({}, onUrlUpdate),
+ });
const filtersWithSpecialChars: ProfileFilter[] = [
{
@@ -440,15 +404,19 @@ describe('useProfileFiltersUrlState', () => {
});
await waitFor(() => {
- expect(mockNavigateTo).toHaveBeenCalled();
- const [, params] = mockNavigateTo.mock.calls[mockNavigateTo.mock.calls.length - 1];
- // Value should be URL encoded
- expect(params.profile_filters).toContain('std%3A%3Avector%3Cint%3E');
+ expect(onUrlUpdate).toHaveBeenCalled();
+ const lastCall = onUrlUpdate.mock.calls[onUrlUpdate.mock.calls.length - 1][0];
+ const filterValue = lastCall.searchParams.get('profile_filters');
+ // The value should contain the encoded special characters
+ expect(filterValue).toContain('std%3A%3Avector%3Cint%3E');
});
});
it('should filter out incomplete filters when encoding', async () => {
- const {result} = renderHook(() => useProfileFiltersUrlState(), {wrapper: createWrapper()});
+ const onUrlUpdate = vi.fn();
+ const {result} = renderHook(() => useProfileFiltersUrlState(), {
+ wrapper: createWrapper({}, onUrlUpdate),
+ });
const incompleteFilters: ProfileFilter[] = [
{
@@ -476,10 +444,10 @@ describe('useProfileFiltersUrlState', () => {
});
await waitFor(() => {
- expect(mockNavigateTo).toHaveBeenCalled();
- const [, params] = mockNavigateTo.mock.calls[mockNavigateTo.mock.calls.length - 1];
+ expect(onUrlUpdate).toHaveBeenCalled();
+ const lastCall = onUrlUpdate.mock.calls[onUrlUpdate.mock.calls.length - 1][0];
// Only the complete filter should be encoded
- expect(params.profile_filters).toBe('f:b:!~:valid');
+ expect(lastCall.searchParams.get('profile_filters')).toBe('f:b:!~:valid');
});
});
});
@@ -501,9 +469,9 @@ describe('useProfileFiltersUrlState', () => {
});
it('should return correctly structured filters from URL', async () => {
- mockLocation.search = '?profile_filters=s:fn:=:testFunc';
-
- const {result} = renderHook(() => useProfileFiltersUrlState(), {wrapper: createWrapper()});
+ const {result} = renderHook(() => useProfileFiltersUrlState(), {
+ wrapper: createWrapper({profile_filters: 's:fn:=:testFunc'}),
+ });
await waitFor(() => {
expect(result.current.appliedFilters).toHaveLength(1);
@@ -522,15 +490,16 @@ describe('useProfileFiltersUrlState', () => {
describe('View switching scenarios', () => {
it('should completely replace filters when switching views using forceApplyFilters', async () => {
- // Start with View A's filters (2 filters)
- mockLocation.search = '?profile_filters=s:fn:=:viewAFunc,f:b:!=:viewABinary';
-
- const {result} = renderHook(() => useProfileFiltersUrlState(), {wrapper: createWrapper()});
+ const onUrlUpdate = vi.fn();
+ const {result} = renderHook(() => useProfileFiltersUrlState(), {
+ wrapper: createWrapper(
+ {profile_filters: 's:fn:=:viewAFunc,f:b:!=:viewABinary'},
+ onUrlUpdate
+ ),
+ });
await waitFor(() => {
expect(result.current.appliedFilters).toHaveLength(2);
- expect(result.current.appliedFilters[0].value).toBe('viewAFunc');
- expect(result.current.appliedFilters[1].value).toBe('viewABinary');
});
// Switch to View B (completely different filter)
@@ -549,96 +518,28 @@ describe('useProfileFiltersUrlState', () => {
});
await waitFor(() => {
- expect(mockNavigateTo).toHaveBeenCalled();
- const [, params] = mockNavigateTo.mock.calls[mockNavigateTo.mock.calls.length - 1];
+ expect(onUrlUpdate).toHaveBeenCalled();
+ const lastCall = onUrlUpdate.mock.calls[onUrlUpdate.mock.calls.length - 1][0];
+ const filterValue = lastCall.searchParams.get('profile_filters');
// View A's filters should be completely gone
- expect(params.profile_filters).not.toContain('viewAFunc');
- expect(params.profile_filters).not.toContain('viewABinary');
+ expect(filterValue).not.toContain('viewAFunc');
+ expect(filterValue).not.toContain('viewABinary');
// Only View B's filter should be present
- expect(params.profile_filters).toBe('f:fn:~:viewBOnly');
- });
- });
-
- it('should handle sequential view switches correctly', async () => {
- // Simulate: [default] -> [storage] -> [testing-view]
- const {result} = renderHook(() => useProfileFiltersUrlState(), {wrapper: createWrapper()});
-
- // View 1: default view (1 filter)
- const defaultFilters: ProfileFilter[] = [{id: 'd-1', type: 'hide_libc', value: 'enabled'}];
-
- act(() => {
- result.current.forceApplyFilters(defaultFilters);
- });
-
- await waitFor(() => {
- expect(mockNavigateTo).toHaveBeenCalled();
- const [, params] = mockNavigateTo.mock.calls[mockNavigateTo.mock.calls.length - 1];
- expect(params.profile_filters).toBe('p:hide_libc:enabled');
- });
-
- mockNavigateTo.mockClear();
-
- // View 2: storage view (3 filters)
- const storageFilters: ProfileFilter[] = [
- {id: 's-1', type: 'stack', field: 'function_name', matchType: 'not_contains', value: 'io'},
- {id: 's-2', type: 'frame', field: 'binary', matchType: 'not_contains', value: 'disk'},
- {id: 's-3', type: 'frame', field: 'function_name', matchType: 'contains', value: 'storage'},
- ];
-
- act(() => {
- result.current.forceApplyFilters(storageFilters);
- });
-
- await waitFor(() => {
- expect(mockNavigateTo).toHaveBeenCalled();
- const [, params] = mockNavigateTo.mock.calls[mockNavigateTo.mock.calls.length - 1];
- // Default view's filter should be gone
- expect(params.profile_filters).not.toContain('hide_libc');
- // Storage view should have 3 filters
- expect(params.profile_filters).toContain('io');
- expect(params.profile_filters).toContain('disk');
- expect(params.profile_filters).toContain('storage');
- });
-
- mockNavigateTo.mockClear();
-
- // View 3: testing-view (2 filters)
- const testingFilters: ProfileFilter[] = [
- {id: 't-1', type: 'stack', field: 'function_name', matchType: 'equal', value: 'test_main'},
- {id: 't-2', type: 'frame', field: 'binary', matchType: 'contains', value: 'test'},
- ];
-
- act(() => {
- result.current.forceApplyFilters(testingFilters);
- });
-
- await waitFor(() => {
- expect(mockNavigateTo).toHaveBeenCalled();
- const [, params] = mockNavigateTo.mock.calls[mockNavigateTo.mock.calls.length - 1];
- // Storage view's filters should be gone
- expect(params.profile_filters).not.toContain('io');
- expect(params.profile_filters).not.toContain('disk');
- expect(params.profile_filters).not.toContain('storage');
- // Testing view should have its 2 filters
- expect(params.profile_filters).toContain('test_main');
- expect(params.profile_filters).toContain('test');
+ expect(filterValue).toBe('f:fn:~:viewBOnly');
});
});
it('should not change filters when clicking the same view tab', async () => {
- // Start with existing filters
- mockLocation.search = '?profile_filters=s:fn:=:existingFilter';
-
- const {result} = renderHook(() => useProfileFiltersUrlState(), {wrapper: createWrapper()});
+ const {result} = renderHook(() => useProfileFiltersUrlState(), {
+ wrapper: createWrapper({profile_filters: 's:fn:=:existingFilter'}),
+ });
await waitFor(() => {
expect(result.current.appliedFilters).toHaveLength(1);
});
- mockNavigateTo.mockClear();
-
// Apply the same filters (simulating clicking the same view tab)
const sameFilters: ProfileFilter[] = [
{
diff --git a/ui/packages/shared/profile/src/ProfileView/components/ProfileFilters/useProfileFiltersUrlState.ts b/ui/packages/shared/profile/src/ProfileView/components/ProfileFilters/useProfileFiltersUrlState.ts
index 9cbdd00151b..26de09db100 100644
--- a/ui/packages/shared/profile/src/ProfileView/components/ProfileFilters/useProfileFiltersUrlState.ts
+++ b/ui/packages/shared/profile/src/ProfileView/components/ProfileFilters/useProfileFiltersUrlState.ts
@@ -13,7 +13,8 @@
import {useCallback, useMemo} from 'react';
-import {useURLStateBatch, useURLStateCustom, type ParamValueSetterCustom} from '@parca/components';
+import {createParser, useQueryState} from 'nuqs';
+
import {safeDecode} from '@parca/utilities';
import {isPresetKey} from './filterPresets';
@@ -137,31 +138,32 @@ export const decodeProfileFilters = (encoded: string): ProfileFilter[] => {
}
};
+const profileFiltersParser = createParser({
+ parse: (value: string) => decodeProfileFilters(value),
+ serialize: (value: ProfileFilter[]) => encodeProfileFilters(value),
+ eq: (a, b) => encodeProfileFilters(a) === encodeProfileFilters(b),
+})
+ .withDefault([])
+ .withOptions({history: 'replace'});
+
export const useProfileFiltersUrlState = (): {
appliedFilters: ProfileFilter[];
- setAppliedFilters: ParamValueSetterCustom;
+ setAppliedFilters: (filters: ProfileFilter[]) => void;
forceApplyFilters: (filters: ProfileFilter[]) => void;
} => {
- const batchUpdates = useURLStateBatch();
-
- // Store applied filters in URL state for persistence using compact encoding
- const [appliedFilters, setAppliedFilters] = useURLStateCustom(
- `profile_filters`,
- {
- parse: value => {
- return decodeProfileFilters(value as string);
- },
- stringify: value => {
- return encodeProfileFilters(value);
- },
- defaultValue: [],
- }
- );
+ const [appliedFilters, setRawFilters] = useQueryState('profile_filters', profileFiltersParser);
const memoizedAppliedFilters = useMemo(() => {
return appliedFilters ?? [];
}, [appliedFilters]);
+ const setAppliedFilters = useCallback(
+ (filters: ProfileFilter[]) => {
+ void setRawFilters(filters);
+ },
+ [setRawFilters]
+ );
+
// Force apply filters (bypasses preserve-existing strategy)
const forceApplyFilters = useCallback(
(filters: ProfileFilter[]) => {
@@ -172,11 +174,9 @@ export const useProfileFiltersUrlState = (): {
return f.value !== '' && f.type != null && f.field != null && f.matchType != null;
});
- batchUpdates(() => {
- setAppliedFilters(validFilters);
- });
+ setAppliedFilters(validFilters);
},
- [batchUpdates, setAppliedFilters]
+ [setAppliedFilters]
);
return {
diff --git a/ui/packages/shared/profile/src/ProfileView/components/Toolbars/MultiLevelDropdown.tsx b/ui/packages/shared/profile/src/ProfileView/components/Toolbars/MultiLevelDropdown.tsx
index 85d88da10e3..f895c1f3898 100644
--- a/ui/packages/shared/profile/src/ProfileView/components/Toolbars/MultiLevelDropdown.tsx
+++ b/ui/packages/shared/profile/src/ProfileView/components/Toolbars/MultiLevelDropdown.tsx
@@ -16,8 +16,8 @@ import React, {useCallback, useEffect, useRef, useState} from 'react';
import {Menu} from '@headlessui/react';
import {Icon} from '@iconify/react';
import cx from 'classnames';
+import {useQueryState} from 'nuqs';
-import {useURLState} from '@parca/components';
import {USER_PREFERENCES, useUserPreference} from '@parca/hooks';
import {ProfileType} from '@parca/parser';
@@ -27,6 +27,7 @@ import {
FIELD_LOCATION_ADDRESS,
FIELD_MAPPING_FILE,
} from '../../../ProfileFlameGraph/FlameGraphArrow';
+import {boolParam, hiddenBinariesParser, stringParam} from '../../../hooks/urlParsers';
import {useProfileViewContext} from '../../context/ProfileViewContext';
import SwitchMenuItem from './SwitchMenuItem';
@@ -206,14 +207,15 @@ const MultiLevelDropdown: React.FC = ({
}) => {
const dropdownRef = useRef(null);
const [shouldOpenLeft, setShouldOpenLeft] = useState(false);
- const [storeSortBy] = useURLState('sort_by', {
- defaultValue: FIELD_FUNCTION_NAME,
- });
- const [colorStackLegend, setStoreColorStackLegend] = useURLState('color_stack_legend');
- const [hiddenBinaries, setHiddenBinaries] = useURLState('hidden_binaries', {
- defaultValue: [],
- alwaysReturnArray: true,
- });
+ const [storeSortBy] = useQueryState('sort_by', stringParam.withDefault(FIELD_FUNCTION_NAME));
+ const [colorStackLegend, setStoreColorStackLegend] = useQueryState(
+ 'color_stack_legend',
+ stringParam
+ );
+ const [hiddenBinaries, setHiddenBinaries] = useQueryState(
+ 'hidden_binaries',
+ hiddenBinariesParser
+ );
const {compareMode} = useProfileViewContext();
const [colorProfileName] = useUserPreference(
USER_PREFERENCES.FLAMEGRAPH_COLOR_PROFILE.key
@@ -223,11 +225,10 @@ const MultiLevelDropdown: React.FC = ({
// By default, we want delta profiles (CPU) to be relatively compared.
// For non-delta profiles, like goroutines or memory, we want the profiles to be compared absolutely.
- const compareAbsoluteDefault = profileType?.delta === false ? 'true' : 'false';
+ const compareAbsoluteDefault = profileType?.delta === false;
- const [compareAbsolute = compareAbsoluteDefault, setCompareAbsolute] =
- useURLState('compare_absolute');
- const isCompareAbsolute = compareAbsolute === 'true';
+ const [compareAbsolute, setCompareAbsolute] = useQueryState('compare_absolute', boolParam);
+ const isCompareAbsolute = compareAbsolute ?? compareAbsoluteDefault;
useEffect(() => {
const checkOverflow = (): void => {
@@ -248,20 +249,20 @@ const MultiLevelDropdown: React.FC = ({
}, [isTableVizOnly]);
const handleBinaryToggle = (index: number): void => {
- const updatedBinaries = [...(hiddenBinaries as string[])];
+ const updatedBinaries = [...hiddenBinaries];
updatedBinaries.splice(index, 1);
- setHiddenBinaries(updatedBinaries);
+ void setHiddenBinaries(updatedBinaries);
};
const setColorStackLegend = useCallback(
(value: string): void => {
- setStoreColorStackLegend(value);
+ void setStoreColorStackLegend(value);
},
[setStoreColorStackLegend]
);
const resetLegend = (): void => {
- setHiddenBinaries([]);
+ void setHiddenBinaries([]);
};
const menuItems: MenuItemType[] = [
@@ -329,7 +330,7 @@ const MultiLevelDropdown: React.FC = ({
},
{
label: isCompareAbsolute ? 'Compare Relative' : 'Compare Absolute',
- onclick: () => setCompareAbsolute(isCompareAbsolute ? 'false' : 'true'),
+ onclick: () => void setCompareAbsolute(!isCompareAbsolute),
hide: !compareMode,
icon: isCompareAbsolute ? 'fluent-mdl2:compare' : 'fluent-mdl2:compare-uneven',
},
@@ -359,7 +360,7 @@ const MultiLevelDropdown: React.FC = ({
},
{
label: 'Reset Legend',
- hide: hiddenBinaries === undefined || hiddenBinaries.length === 0,
+ hide: hiddenBinaries.length === 0,
onclick: () => resetLegend(),
id: 'h-reset-legend-button',
icon: 'system-uicons:reset',
@@ -367,7 +368,7 @@ const MultiLevelDropdown: React.FC = ({
{
label: 'Hidden Binaries',
id: 'h-hidden-binaries',
- items: (hiddenBinaries as string[])?.map((binary, index) => ({
+ items: hiddenBinaries.map((binary, index) => ({
label: binary,
customSubmenu: (
@@ -383,7 +384,7 @@ const MultiLevelDropdown: React.FC = ({
),
})),
- hide: hiddenBinaries === undefined || hiddenBinaries.length === 0,
+ hide: hiddenBinaries.length === 0,
icon: 'ph:eye-closed',
},
];
@@ -424,10 +425,8 @@ const MultiLevelDropdown: React.FC = ({
{...item}
onSelect={onSelect}
closeDropdown={close}
- activeValueForSortBy={storeSortBy as string}
- activeValueForColorBy={
- colorBy === undefined || colorBy === '' ? 'binary' : colorBy
- }
+ activeValueForSortBy={storeSortBy}
+ activeValueForColorBy={colorBy}
activeValuesForLevel={groupBy}
renderAsDiv={item.renderAsDiv}
/>
diff --git a/ui/packages/shared/profile/src/ProfileView/components/Toolbars/TableColumnsDropdown.tsx b/ui/packages/shared/profile/src/ProfileView/components/Toolbars/TableColumnsDropdown.tsx
index c6e675a7d89..4ab4c50bb84 100644
--- a/ui/packages/shared/profile/src/ProfileView/components/Toolbars/TableColumnsDropdown.tsx
+++ b/ui/packages/shared/profile/src/ProfileView/components/Toolbars/TableColumnsDropdown.tsx
@@ -14,14 +14,15 @@
import {useEffect, useMemo, useState} from 'react';
import {createColumnHelper, type ColumnDef} from '@tanstack/table-core';
+import {useQueryState} from 'nuqs';
-import {useURLState} from '@parca/components';
import {ProfileType} from '@parca/parser';
import {valueFormatter} from '@parca/utilities';
import {Row} from '../../../Table';
import ColumnsVisibility from '../../../Table/ColumnsVisibility';
import {ColumnName, addPlusSign, getRatioString} from '../../../Table/utils/functions';
+import {tableColumnsParser} from '../../../hooks/urlParsers';
import {useProfileViewContext} from '../../context/ProfileViewContext';
interface Props {
@@ -32,9 +33,7 @@ interface Props {
const TableColumnsDropdown = ({profileType, total, filtered}: Props): JSX.Element => {
const {compareMode} = useProfileViewContext();
- const [tableColumns, setTableColumns] = useURLState('table_columns', {
- alwaysReturnArray: true,
- });
+ const [tableColumns, setTableColumns] = useQueryState('table_columns', tableColumnsParser);
const columnHelper = createColumnHelper();
@@ -190,7 +189,7 @@ const TableColumnsDropdown = ({profileType, total, filtered}: Props): JSX.Elemen
const newTableColumns = (Object.keys(updatedColumns) as ColumnName[]).filter(
col => updatedColumns[col]
);
- setTableColumns(newTableColumns);
+ void setTableColumns(newTableColumns);
};
return (
diff --git a/ui/packages/shared/profile/src/ProfileView/components/Toolbars/index.tsx b/ui/packages/shared/profile/src/ProfileView/components/Toolbars/index.tsx
index 9e98d59e670..2e1e5bbf19b 100644
--- a/ui/packages/shared/profile/src/ProfileView/components/Toolbars/index.tsx
+++ b/ui/packages/shared/profile/src/ProfileView/components/Toolbars/index.tsx
@@ -50,7 +50,7 @@ export interface VisualisationToolbarProps {
flamechartDimension: string[];
setFlamechartDimension: (labels: string[]) => void;
showVisualizationSelector?: boolean;
- sandwichFunctionName?: string;
+ sandwichFunctionName: string | null;
alignFunctionName: string;
setAlignFunctionName: (align: string) => void;
colorBy: string;
@@ -75,7 +75,7 @@ export interface FlameGraphToolbarProps {
export interface SandwichFlameGraphToolbarProps {
resetSandwichFunctionName: () => void;
- sandwichFunctionName?: string;
+ sandwichFunctionName: string | null;
}
export const TableToolbar: FC = ({profileType, total, filtered}) => {
@@ -120,7 +120,7 @@ export const SandwichFlameGraphToolbar: FC = ({
onClick={() => resetSandwichFunctionName()}
className="w-auto"
variant="neutral"
- disabled={sandwichFunctionName === undefined || sandwichFunctionName.length === 0}
+ disabled={sandwichFunctionName == null || sandwichFunctionName.length === 0}
>
Reset view
diff --git a/ui/packages/shared/profile/src/ProfileView/components/ViewSelector/index.tsx b/ui/packages/shared/profile/src/ProfileView/components/ViewSelector/index.tsx
index 6223fe2a4ac..4f1f4c4ecec 100644
--- a/ui/packages/shared/profile/src/ProfileView/components/ViewSelector/index.tsx
+++ b/ui/packages/shared/profile/src/ProfileView/components/ViewSelector/index.tsx
@@ -13,9 +13,12 @@
import {ReactNode} from 'react';
-import {useParcaContext, useURLState, useURLStateBatch} from '@parca/components';
+import {useQueryState} from 'nuqs';
+
+import {useParcaContext} from '@parca/components';
import {ProfileSource} from '../../../ProfileSource';
+import {dashboardItemsParser, stringParam} from '../../../hooks/urlParsers';
import Dropdown, {DropdownElement, InnerAction} from './Dropdown';
interface Props {
@@ -23,15 +26,12 @@ interface Props {
}
const ViewSelector = ({profileSource}: Props): JSX.Element => {
- const [dashboardItems = ['flamegraph'], setDashboardItems] = useURLState(
+ const [dashboardItems, setDashboardItems] = useQueryState(
'dashboard_items',
- {
- alwaysReturnArray: true,
- }
+ dashboardItemsParser
);
- const [, setSandwichFunctionName] = useURLState('sandwich_function_name');
+ const [, setSandwichFunctionName] = useQueryState('sandwich_function_name', stringParam);
const {enableSourcesView, enableSandwichView} = useParcaContext();
- const batchUpdates = useURLStateBatch();
const allItems: Array<{
key: string;
@@ -125,18 +125,13 @@ const ViewSelector = ({profileSource}: Props): JSX.Element => {
: 'Add Panel',
onClick: () => {
if (item.canBeSelected) {
- setDashboardItems([...dashboardItems, item.key]);
+ void setDashboardItems([...dashboardItems, item.key]);
} else {
const newDashboardItems = dashboardItems.filter(v => v !== item.key);
- // Batch updates when removing sandwich panel to combine both URL changes
+ void setDashboardItems(newDashboardItems);
if (item.key === 'sandwich') {
- batchUpdates(() => {
- setDashboardItems(newDashboardItems);
- setSandwichFunctionName(undefined);
- });
- } else {
- setDashboardItems(newDashboardItems);
+ void setSandwichFunctionName(null);
}
}
},
@@ -156,18 +151,18 @@ const ViewSelector = ({profileSource}: Props): JSX.Element => {
const isOnlyChart = dashboardItems.length === 1;
if (isOnlyChart && value === 'sandwich') {
- setDashboardItems([...dashboardItems, value]);
+ void setDashboardItems([...dashboardItems, value]);
return;
}
if (isOnlyChart) {
- setDashboardItems([value]);
+ void setDashboardItems([value]);
return;
}
const newDashboardItems = [dashboardItems[0], value];
- setDashboardItems(newDashboardItems);
+ void setDashboardItems(newDashboardItems);
};
return (
diff --git a/ui/packages/shared/profile/src/ProfileView/context/DashboardContext.tsx b/ui/packages/shared/profile/src/ProfileView/context/DashboardContext.tsx
index 77839445b46..57f13d2bc32 100644
--- a/ui/packages/shared/profile/src/ProfileView/context/DashboardContext.tsx
+++ b/ui/packages/shared/profile/src/ProfileView/context/DashboardContext.tsx
@@ -11,10 +11,11 @@
// See the License for the specific language governing permissions and
// limitations under the License.
-import {FC, PropsWithChildren, createContext, useContext} from 'react';
+import {FC, PropsWithChildren, createContext, useCallback, useContext} from 'react';
-import {useURLState} from '@parca/components';
+import {useQueryState} from 'nuqs';
+import {dashboardItemsParser, stringParam} from '../../hooks/urlParsers';
import {VisualizationType} from '../types/visualization';
interface DashboardContextType {
@@ -27,10 +28,18 @@ interface DashboardContextType {
const DashboardContext = createContext(undefined);
export const DashboardProvider: FC = ({children}) => {
- const [dashboardItems, setDashboardItems] = useURLState('dashboard_items', {
- alwaysReturnArray: true,
- });
- const [, setSandwichFunctionName] = useURLState('sandwich_function_name');
+ const [dashboardItems, setRawDashboardItems] = useQueryState(
+ 'dashboard_items',
+ dashboardItemsParser
+ );
+
+ const setDashboardItems = useCallback(
+ (items: string[]) => {
+ void setRawDashboardItems(items);
+ },
+ [setRawDashboardItems]
+ );
+ const [, setSandwichFunctionName] = useQueryState('sandwich_function_name', stringParam);
const handleClosePanel = (visualizationType: VisualizationType): void => {
const newDashboardItems = dashboardItems.filter(item => item !== visualizationType);
@@ -38,7 +47,7 @@ export const DashboardProvider: FC = ({children}) => {
// Reset sandwich function name when closing sandwich panel
if (visualizationType === 'sandwich') {
- setSandwichFunctionName(undefined);
+ void setSandwichFunctionName(null);
}
};
diff --git a/ui/packages/shared/profile/src/ProfileView/hooks/useResetFlameGraphState.ts b/ui/packages/shared/profile/src/ProfileView/hooks/useResetFlameGraphState.ts
index 1cb785d4e42..5dbe6db8cb5 100644
--- a/ui/packages/shared/profile/src/ProfileView/hooks/useResetFlameGraphState.ts
+++ b/ui/packages/shared/profile/src/ProfileView/hooks/useResetFlameGraphState.ts
@@ -11,17 +11,19 @@
// See the License for the specific language governing permissions and
// limitations under the License.
-import {useURLState} from '@parca/components';
+import {useQueryState} from 'nuqs';
+
+import {stringParam} from '../../hooks/urlParsers';
export const useResetFlameGraphState = (): (() => void) => {
- const [val, setCurPath] = useURLState('cur_path');
+ const [val, setCurPath] = useQueryState('cur_path', stringParam);
return () => {
setTimeout(() => {
- if (val === undefined) {
+ if (val === null) {
return;
}
- setCurPath(undefined);
+ void setCurPath(null);
});
};
};
diff --git a/ui/packages/shared/profile/src/ProfileView/hooks/useResetStateOnProfileTypeChange.ts b/ui/packages/shared/profile/src/ProfileView/hooks/useResetStateOnProfileTypeChange.ts
index b4bda7ad6ad..8f3ced61b23 100644
--- a/ui/packages/shared/profile/src/ProfileView/hooks/useResetStateOnProfileTypeChange.ts
+++ b/ui/packages/shared/profile/src/ProfileView/hooks/useResetStateOnProfileTypeChange.ts
@@ -11,39 +11,37 @@
// See the License for the specific language governing permissions and
// limitations under the License.
-import {useURLState, useURLStateBatch} from '@parca/components';
+import {useQueryStates} from 'nuqs';
+import {stringParam} from '../../hooks/urlParsers';
import {useProfileFilters} from '../components/ProfileFilters/useProfileFilters';
export const useResetStateOnProfileTypeChange = (): (() => void) => {
- const [groupBy, setGroupBy] = useURLState('group_by');
- const [curPath, setCurPath] = useURLState('cur_path');
- const [sumByA, setSumByA] = useURLState('sum_by_a');
- const [sumByB, setSumByB] = useURLState('sum_by_b');
+ const [state, setState] = useQueryStates(
+ {
+ group_by: stringParam,
+ cur_path: stringParam,
+ sum_by_a: stringParam,
+ sum_by_b: stringParam,
+ sandwich_function_name: stringParam,
+ },
+ {history: 'replace'}
+ );
const {resetFilters} = useProfileFilters();
- const [sandwichFunctionName, setSandwichFunctionName] = useURLState('sandwich_function_name');
- const batchUpdates = useURLStateBatch();
return () => {
- // Batch all URL state resets into a single navigation
- batchUpdates(() => {
- if (groupBy !== undefined) {
- setGroupBy(undefined);
- }
- if (curPath !== undefined) {
- setCurPath(undefined);
- }
- if (sandwichFunctionName !== undefined) {
- setSandwichFunctionName(undefined);
- }
- if (sumByA !== undefined) {
- setSumByA(undefined);
- }
- if (sumByB !== undefined) {
- setSumByB(undefined);
- }
+ // Atomic reset: clear all params in single URL update
+ const updates: Record = {};
+ if (state.group_by !== null) updates.group_by = null;
+ if (state.cur_path !== null) updates.cur_path = null;
+ if (state.sandwich_function_name !== null) updates.sandwich_function_name = null;
+ if (state.sum_by_a !== null) updates.sum_by_a = null;
+ if (state.sum_by_b !== null) updates.sum_by_b = null;
- resetFilters();
- });
+ if (Object.keys(updates).length > 0) {
+ void setState(updates);
+ }
+
+ resetFilters();
};
};
diff --git a/ui/packages/shared/profile/src/ProfileView/hooks/useResetStateOnSeriesChange.ts b/ui/packages/shared/profile/src/ProfileView/hooks/useResetStateOnSeriesChange.ts
index c18eaacd820..5af90685ce7 100644
--- a/ui/packages/shared/profile/src/ProfileView/hooks/useResetStateOnSeriesChange.ts
+++ b/ui/packages/shared/profile/src/ProfileView/hooks/useResetStateOnSeriesChange.ts
@@ -11,19 +11,27 @@
// See the License for the specific language governing permissions and
// limitations under the License.
-import {useURLState} from '@parca/components';
+import {useQueryStates} from 'nuqs';
+
+import {stringParam} from '../../hooks/urlParsers';
export const useResetStateOnSeriesChange = (): (() => void) => {
- const [curPath, setCurPath] = useURLState('cur_path');
- const [sandwichFunctionName, setSandwichFunctionName] = useURLState('sandwich_function_name');
+ const [state, setState] = useQueryStates(
+ {
+ cur_path: stringParam,
+ sandwich_function_name: stringParam,
+ },
+ {history: 'replace'}
+ );
return () => {
setTimeout(() => {
- if (curPath !== undefined) {
- setCurPath(undefined);
- }
- if (sandwichFunctionName !== undefined) {
- setSandwichFunctionName(undefined);
+ const updates: Record = {};
+ if (state.cur_path !== null) updates.cur_path = null;
+ if (state.sandwich_function_name !== null) updates.sandwich_function_name = null;
+
+ if (Object.keys(updates).length > 0) {
+ void setState(updates);
}
});
};
diff --git a/ui/packages/shared/profile/src/ProfileView/hooks/useVisualizationState.ts b/ui/packages/shared/profile/src/ProfileView/hooks/useVisualizationState.ts
index 05f9aeca134..1a23683f46b 100644
--- a/ui/packages/shared/profile/src/ProfileView/hooks/useVisualizationState.ts
+++ b/ui/packages/shared/profile/src/ProfileView/hooks/useVisualizationState.ts
@@ -13,13 +13,8 @@
import {useCallback, useMemo} from 'react';
-import {
- JSONParser,
- JSONSerializer,
- useURLState,
- useURLStateBatch,
- useURLStateCustom,
-} from '@parca/components';
+import {useQueryState} from 'nuqs';
+
import {USER_PREFERENCES, useUserPreference} from '@parca/hooks';
import {
@@ -30,12 +25,19 @@ import {
FIELD_MAPPING_FILE,
} from '../../ProfileFlameGraph/FlameGraphArrow';
import {CurrentPathFrame} from '../../ProfileFlameGraph/FlameGraphArrow/utils';
+import {
+ colorByParser,
+ flamechartDimensionParser,
+ groupByParser,
+ jsonParser,
+ stringParam,
+} from '../../hooks/urlParsers';
import {useResetFlameGraphState} from './useResetFlameGraphState';
export const useVisualizationState = (): {
curPathArrow: CurrentPathFrame[];
setCurPathArrow: (path: CurrentPathFrame[]) => void;
- colorStackLegend: string | undefined;
+ colorStackLegend: string | null;
colorBy: string;
setColorBy: (colorBy: string) => void;
groupBy: string[];
@@ -44,8 +46,8 @@ export const useVisualizationState = (): {
setGroupByLabels: (labels: string[]) => void;
flamechartDimension: string[];
setFlamechartDimension: (labels: string[]) => void;
- sandwichFunctionName: string | undefined;
- setSandwichFunctionName: (sandwichFunctionName: string | undefined) => void;
+ sandwichFunctionName: string | null;
+ setSandwichFunctionName: (sandwichFunctionName: string | null) => void;
resetSandwichFunctionName: () => void;
alignFunctionName: string;
setAlignFunctionName: (align: string) => void;
@@ -57,33 +59,42 @@ export const useVisualizationState = (): {
USER_PREFERENCES.ALIGN_FUNCTION_NAME.key
);
- const [curPathArrow, setCurPathArrow] = useURLStateCustom('cur_path', {
- parse: JSONParser,
- stringify: JSONSerializer,
- defaultValue: '[]',
- });
- const [colorStackLegend] = useURLState('color_stack_legend');
- const [colorBy, setStoreColorBy] = useURLState('color_by', {
- defaultValue: colorByPreference,
- });
- const [alignFunctionName, setStoreAlignFunctionName] = useURLState('align_function_name', {
- defaultValue: alignFunctionNamePreference,
- });
- const [groupBy, setStoreGroupBy] = useURLState('group_by', {
- defaultValue: [FIELD_FUNCTION_NAME],
- alwaysReturnArray: true,
- });
- const [sandwichFunctionName, setSandwichFunctionName] = useURLState(
- 'sandwich_function_name'
- );
- const [flamechartDimension, setStoreFlamechartDimension] = useURLState(
+ const [curPathArrow, setRawCurPathArrow] = useQueryState(
+ 'cur_path',
+ jsonParser().withDefault([])
+ );
+ const setCurPathArrow = useCallback(
+ (path: CurrentPathFrame[]) => {
+ void setRawCurPathArrow(path);
+ },
+ [setRawCurPathArrow]
+ );
+ const [colorStackLegend] = useQueryState('color_stack_legend', stringParam);
+ const [colorBy, setStoreColorBy] = useQueryState('color_by', colorByParser);
+ const [alignFunctionNameRaw, setStoreAlignFunctionName] = useQueryState(
+ 'align_function_name',
+ stringParam
+ );
+ const alignFunctionName = alignFunctionNameRaw ?? alignFunctionNamePreference ?? 'left';
+ const [groupBy, setStoreGroupBy] = useQueryState(
+ 'group_by',
+ groupByParser.withDefault([FIELD_FUNCTION_NAME])
+ );
+ const [sandwichFunctionName, setRawSandwichFunctionName] = useQueryState(
+ 'sandwich_function_name',
+ stringParam
+ );
+ const setSandwichFunctionName = useCallback(
+ (name: string | null) => {
+ void setRawSandwichFunctionName(name);
+ },
+ [setRawSandwichFunctionName]
+ );
+ const [flamechartDimension, setStoreFlamechartDimension] = useQueryState(
'flamechart_dimension',
- {
- alwaysReturnArray: true,
- }
+ flamechartDimensionParser.withDefault([])
);
const resetFlameGraphState = useResetFlameGraphState();
- const batchUpdates = useURLStateBatch();
const levelsOfProfiling = useMemo(
() => [
@@ -97,54 +108,46 @@ export const useVisualizationState = (): {
const setGroupBy = useCallback(
(keys: string[]): void => {
- setStoreGroupBy(keys);
+ void setStoreGroupBy(keys);
},
[setStoreGroupBy]
);
const toggleGroupBy = useCallback(
(key: string): void => {
- // Batch updates to combine setGroupBy + resetFlameGraphState into single URL navigation
- batchUpdates(() => {
- if (groupBy.includes(key)) {
- setGroupBy(groupBy.filter(v => v !== key)); // remove
- } else {
- const filteredGroupBy = groupBy.filter(item => !levelsOfProfiling.includes(item));
- setGroupBy([...filteredGroupBy, key]); // add
- }
-
- resetFlameGraphState();
- });
+ if (groupBy.includes(key)) {
+ setGroupBy(groupBy.filter(v => v !== key));
+ } else {
+ const filteredGroupBy = groupBy.filter(item => !levelsOfProfiling.includes(item));
+ setGroupBy([...filteredGroupBy, key]);
+ }
+ resetFlameGraphState();
},
- [groupBy, setGroupBy, levelsOfProfiling, resetFlameGraphState, batchUpdates]
+ [groupBy, setGroupBy, levelsOfProfiling, resetFlameGraphState]
);
const setGroupByLabels = useCallback(
(labels: string[]): void => {
- // Batch updates to combine setGroupBy + resetFlameGraphState into single URL navigation
- batchUpdates(() => {
- setGroupBy(groupBy.filter(l => !l.startsWith(`${FIELD_LABELS}.`)).concat(labels));
-
- resetFlameGraphState();
- });
+ setGroupBy(groupBy.filter(l => !l.startsWith(`${FIELD_LABELS}.`)).concat(labels));
+ resetFlameGraphState();
},
- [groupBy, setGroupBy, resetFlameGraphState, batchUpdates]
+ [groupBy, setGroupBy, resetFlameGraphState]
);
const setFlamechartDimension = useCallback(
(labels: string[]): void => {
- setStoreFlamechartDimension(labels.filter(l => l.startsWith(`${FIELD_LABELS}.`)));
+ void setStoreFlamechartDimension(labels.filter(l => l.startsWith(`${FIELD_LABELS}.`)));
},
[setStoreFlamechartDimension]
);
const resetSandwichFunctionName = useCallback((): void => {
- setSandwichFunctionName(undefined);
+ setSandwichFunctionName(null);
}, [setSandwichFunctionName]);
const setColorBy = useCallback(
(value: string): void => {
- setStoreColorBy(value);
+ void setStoreColorBy(value);
setColorByPreference(value);
},
[setStoreColorBy, setColorByPreference]
@@ -152,7 +155,7 @@ export const useVisualizationState = (): {
const setAlignFunctionName = useCallback(
(value: string): void => {
- setStoreAlignFunctionName(value);
+ void setStoreAlignFunctionName(value);
setAlignFunctionNamePreference(value);
},
[setStoreAlignFunctionName, setAlignFunctionNamePreference]
@@ -162,7 +165,7 @@ export const useVisualizationState = (): {
curPathArrow,
setCurPathArrow,
colorStackLegend,
- colorBy: (colorBy as string) ?? '',
+ colorBy,
setColorBy,
groupBy,
setGroupBy,
@@ -173,7 +176,7 @@ export const useVisualizationState = (): {
sandwichFunctionName,
setSandwichFunctionName,
resetSandwichFunctionName,
- alignFunctionName: (alignFunctionName as string) ?? 'left',
+ alignFunctionName,
setAlignFunctionName,
};
};
diff --git a/ui/packages/shared/profile/src/ProfileViewWithData.tsx b/ui/packages/shared/profile/src/ProfileViewWithData.tsx
index e78ca33fde5..6f49948e4f3 100644
--- a/ui/packages/shared/profile/src/ProfileViewWithData.tsx
+++ b/ui/packages/shared/profile/src/ProfileViewWithData.tsx
@@ -13,15 +13,10 @@
import {useEffect, useMemo, useState} from 'react';
+import {useQueryState} from 'nuqs';
+
import {QueryRequest_ReportType, QueryServiceClient} from '@parca/client';
-import {
- NumberParser,
- NumberSerializer,
- useGrpcMetadata,
- useParcaContext,
- useURLState,
- useURLStateCustom,
-} from '@parca/components';
+import {useGrpcMetadata, useParcaContext} from '@parca/components';
import {saveAsBlob} from '@parca/utilities';
import {validateFlameChartQuery} from './ProfileFlameGraph';
@@ -35,6 +30,14 @@ import {MergedProfileSource, ProfileSource} from './ProfileSource';
import {ProfileView} from './ProfileView';
import {useProfileFilters} from './ProfileView/components/ProfileFilters/useProfileFilters';
import type {SamplesSeries} from './ProfileView/types/visualization';
+import {
+ dashboardItemsParser,
+ flamechartDimensionParser,
+ groupByParser,
+ intParam,
+ invertCallStackParser,
+ stringParam,
+} from './hooks/urlParsers';
import {useQuery} from './useQuery';
import {downloadPprof} from './utils';
@@ -51,22 +54,17 @@ export const ProfileViewWithData = ({
showVisualizationSelector,
}: ProfileViewWithDataProps): JSX.Element => {
const metadata = useGrpcMetadata();
- const [dashboardItems, setDashboardItems] = useURLState('dashboard_items', {
- alwaysReturnArray: true,
- });
- const [sourceBuildID] = useURLState('source_buildid');
- const [sourceFilename] = useURLState('source_filename');
- const [groupBy] = useURLState('group_by', {
- defaultValue: [FIELD_FUNCTION_NAME],
- alwaysReturnArray: true,
- });
- const [sandwichFunctionName] = useURLState('sandwich_function_name');
- const [flamechartDimension] = useURLState('flamechart_dimension', {
- alwaysReturnArray: true,
- });
+ const [dashboardItems, setDashboardItems] = useQueryState(
+ 'dashboard_items',
+ dashboardItemsParser
+ );
+ const [sourceBuildID] = useQueryState('source_buildid', stringParam);
+ const [sourceFilename] = useQueryState('source_filename', stringParam);
+ const [groupBy] = useQueryState('group_by', groupByParser.withDefault([FIELD_FUNCTION_NAME]));
+ const [sandwichFunctionName] = useQueryState('sandwich_function_name', stringParam);
+ const [flamechartDimension] = useQueryState('flamechart_dimension', flamechartDimensionParser);
- const [invertStack] = useURLState('invert_call_stack');
- const invertCallStack = invertStack === 'true';
+ const [invertCallStack] = useQueryState('invert_call_stack', invertCallStackParser);
const [pprofDownloading, setPprofDownloading] = useState(false);
@@ -88,7 +86,7 @@ export const ProfileViewWithData = ({
if (newDashboardItems.length === 0) {
newDashboardItems = ['flamegraph'];
}
- setDashboardItems(newDashboardItems);
+ void setDashboardItems(newDashboardItems);
}, [profileSource, dashboardItems, setDashboardItems]);
const nodeTrimThreshold = useMemo(() => {
@@ -108,7 +106,7 @@ export const ProfileViewWithData = ({
skip: !dashboardItems.includes('flamegraph'),
nodeTrimThreshold,
groupBy,
- invertCallStack,
+ invertCallStack: invertCallStack ?? false,
protoFilters,
});
@@ -132,11 +130,10 @@ export const ProfileViewWithData = ({
);
// Samples step count: 2px per data point for finer granularity in strips
- const [samplesStepCount] = useURLStateCustom('samples_step_count', {
- defaultValue: String(getStepCountFromScreenWidth(2)),
- parse: NumberParser,
- stringify: NumberSerializer,
- });
+ const [samplesStepCount] = useQueryState(
+ 'samples_step_count',
+ intParam.withDefault(getStepCountFromScreenWidth(2))
+ );
const {
isLoading: samplesLoading,
@@ -201,8 +198,8 @@ export const ProfileViewWithData = ({
error: sourceError,
} = useQuery(queryClient, profileSource, QueryRequest_ReportType.SOURCE, {
skip: !dashboardItems.includes('source'),
- sourceBuildID,
- sourceFilename,
+ sourceBuildID: sourceBuildID ?? undefined,
+ sourceFilename: sourceFilename ?? undefined,
protoFilters,
});
@@ -214,8 +211,8 @@ export const ProfileViewWithData = ({
nodeTrimThreshold,
groupBy: [FIELD_FUNCTION_NAME],
invertCallStack: true,
- sandwichByFunction: sandwichFunctionName,
- skip: sandwichFunctionName === undefined && !dashboardItems.includes('sandwich'),
+ sandwichByFunction: sandwichFunctionName ?? undefined,
+ skip: sandwichFunctionName == null && !dashboardItems.includes('sandwich'),
protoFilters,
});
@@ -227,8 +224,8 @@ export const ProfileViewWithData = ({
nodeTrimThreshold,
groupBy: [FIELD_FUNCTION_NAME],
invertCallStack: false,
- sandwichByFunction: sandwichFunctionName,
- skip: sandwichFunctionName === undefined && !dashboardItems.includes('sandwich'),
+ sandwichByFunction: sandwichFunctionName ?? undefined,
+ skip: sandwichFunctionName == null && !dashboardItems.includes('sandwich'),
protoFilters,
});
diff --git a/ui/packages/shared/profile/src/Sandwich/index.tsx b/ui/packages/shared/profile/src/Sandwich/index.tsx
index 4fa51291025..361a0686f0e 100644
--- a/ui/packages/shared/profile/src/Sandwich/index.tsx
+++ b/ui/packages/shared/profile/src/Sandwich/index.tsx
@@ -14,14 +14,15 @@
import React, {useRef, useState} from 'react';
import {AnimatePresence, motion} from 'framer-motion';
+import {useQueryState} from 'nuqs';
-import {useURLState} from '@parca/components';
import {TEST_IDS, testId} from '@parca/test-utils';
import {ProfileSource} from '../ProfileSource';
import {useDashboard} from '../ProfileView/context/DashboardContext';
import {useVisualizationState} from '../ProfileView/hooks/useVisualizationState';
import {SandwichData} from '../ProfileView/types/visualization';
+import {stringParam} from '../hooks/urlParsers';
import {CalleesSection} from './components/CalleesSection';
import {CallersSection} from './components/CallersSection';
@@ -35,7 +36,7 @@ const Sandwich = React.memo(function Sandwich({
profileSource,
}: Props): React.JSX.Element {
const {dashboardItems} = useDashboard();
- const [sandwichFunctionName] = useURLState('sandwich_function_name');
+ const [sandwichFunctionName] = useQueryState('sandwich_function_name', stringParam);
const callersRef = React.useRef(null);
const calleesRef = React.useRef(null);
@@ -57,7 +58,7 @@ const Sandwich = React.memo(function Sandwich({
transition={{duration: 0.5}}
>
- {sandwichFunctionName !== undefined ? (
+ {sandwichFunctionName != null ? (
('source_filename');
+ const [sourceFileName] = useQueryState('source_filename', stringParam);
const {isDarkMode, sourceViewContextMenuItems = []} = useParcaContext();
const sourceCode = useMemo(() => {
diff --git a/ui/packages/shared/profile/src/SourceView/useSelectedLineRange.ts b/ui/packages/shared/profile/src/SourceView/useSelectedLineRange.ts
index 2ae772f5eec..c460a39f861 100644
--- a/ui/packages/shared/profile/src/SourceView/useSelectedLineRange.ts
+++ b/ui/packages/shared/profile/src/SourceView/useSelectedLineRange.ts
@@ -11,9 +11,25 @@
// See the License for the specific language governing permissions and
// limitations under the License.
-import {useMemo} from 'react';
+import {useCallback} from 'react';
-import {useURLState} from '@parca/components';
+import {createParser, useQueryState} from 'nuqs';
+
+interface SelectedLineRange {
+ start: number;
+ end: number;
+}
+
+const lineRangeParser = createParser({
+ parse: (value: string) => {
+ const [start, end] = value.split('-');
+ const startNum = parseInt(start, 10);
+ if (isNaN(startNum)) return null;
+ const endNum = end !== undefined ? parseInt(end, 10) : startNum;
+ return {start: startNum, end: isNaN(endNum) ? startNum : endNum};
+ },
+ serialize: (value: SelectedLineRange) => `${value.start}-${value.end}`,
+}).withOptions({history: 'replace'});
interface LineRange {
startLine: number;
@@ -22,24 +38,23 @@ interface LineRange {
}
const useLineRange = (): LineRange => {
- const [sourceLine, setSourceLine] = useURLState('source_line');
- const [startLine, endLine] = useMemo(() => {
- if (sourceLine == null) {
- return [-1, -1];
- }
- const [start, end] = sourceLine.split('-');
-
- if (end === undefined) {
- return [parseInt(start, 10), parseInt(start, 10)];
- }
- return [parseInt(start, 10), parseInt(end, 10)];
- }, [sourceLine]);
-
- const setLineRange = (start: number, end: number): void => {
- setSourceLine(`${start}-${end}`);
+ const [lineRange, setRawLineRange] = useQueryState(
+ 'source_line',
+ lineRangeParser.withDefault({start: -1, end: -1})
+ );
+
+ const setLineRange = useCallback(
+ (start: number, end: number): void => {
+ void setRawLineRange({start, end});
+ },
+ [setRawLineRange]
+ );
+
+ return {
+ startLine: lineRange.start,
+ endLine: lineRange.end,
+ setLineRange,
};
-
- return {startLine, endLine, setLineRange};
};
export default useLineRange;
diff --git a/ui/packages/shared/profile/src/Table/MoreDropdown.tsx b/ui/packages/shared/profile/src/Table/MoreDropdown.tsx
index e9cf302d792..ee730ca47e9 100644
--- a/ui/packages/shared/profile/src/Table/MoreDropdown.tsx
+++ b/ui/packages/shared/profile/src/Table/MoreDropdown.tsx
@@ -13,23 +13,23 @@
import {Menu} from '@headlessui/react';
import {Icon} from '@iconify/react';
+import {useQueryState} from 'nuqs';
-import {useParcaContext, useURLState, useURLStateBatch} from '@parca/components';
+import {useParcaContext} from '@parca/components';
+
+import {dashboardItemsParser, stringParam} from '../hooks/urlParsers';
const MoreDropdown = ({functionName}: {functionName: string}): React.JSX.Element | null => {
- const [_, setSandwichFunctionName] = useURLState('sandwich_function_name');
- const [dashboardItems, setDashboardItems] = useURLState('dashboard_items', {
- alwaysReturnArray: true,
- });
+ const [_, setSandwichFunctionName] = useQueryState('sandwich_function_name', stringParam);
+ const [dashboardItems, setDashboardItems] = useQueryState(
+ 'dashboard_items',
+ dashboardItemsParser
+ );
const {enableSandwichView} = useParcaContext();
- const batchUpdates = useURLStateBatch();
const onSandwichViewSelect = (): void => {
- // Batch updates to combine setSandwichFunctionName + setDashboardItems into single URL navigation
- batchUpdates(() => {
- setSandwichFunctionName(functionName.trim());
- setDashboardItems([...dashboardItems, 'sandwich']);
- });
+ void setSandwichFunctionName(functionName.trim());
+ void setDashboardItems([...dashboardItems, 'sandwich']);
};
const menuItems: Array<{label: string; action: () => void}> = [];
diff --git a/ui/packages/shared/profile/src/Table/TableContextMenu.tsx b/ui/packages/shared/profile/src/Table/TableContextMenu.tsx
index f8ece0c7895..3292065aae5 100644
--- a/ui/packages/shared/profile/src/Table/TableContextMenu.tsx
+++ b/ui/packages/shared/profile/src/Table/TableContextMenu.tsx
@@ -13,15 +13,17 @@
import {Icon} from '@iconify/react';
import cx from 'classnames';
+import {useQueryState} from 'nuqs';
import {Item, Menu, Submenu} from 'react-contexify';
import 'react-contexify/dist/ReactContexify.css';
-import {useParcaContext, useURLState, useURLStateBatch} from '@parca/components';
+import {useParcaContext} from '@parca/components';
import {valueFormatter} from '@parca/utilities';
import {type Row} from '.';
import {getTextForCumulative} from '../ProfileFlameGraph/FlameGraphArrow/utils';
+import {dashboardItemsParser, stringParam} from '../hooks/urlParsers';
import {truncateString} from '../utils';
import {type ColumnName} from './utils/functions';
@@ -42,22 +44,19 @@ const TableContextMenu = ({
totalUnfiltered,
columnVisibility,
}: TableContextMenuProps): React.JSX.Element => {
- const [_, setSandwichFunctionName] = useURLState('sandwich_function_name');
- const [dashboardItems, setDashboardItems] = useURLState('dashboard_items', {
- alwaysReturnArray: true,
- });
+ const [_, setSandwichFunctionName] = useQueryState('sandwich_function_name', stringParam);
+ const [dashboardItems, setDashboardItems] = useQueryState(
+ 'dashboard_items',
+ dashboardItemsParser
+ );
const {enableSandwichView, isDarkMode} = useParcaContext();
- const batchUpdates = useURLStateBatch();
const onSandwichViewSelect = (): void => {
if (row?.name != null && row.name.length > 0) {
- // Batch updates to combine setSandwichFunctionName + setDashboardItems into single URL navigation
- batchUpdates(() => {
- setSandwichFunctionName(row.name.trim());
- if (!dashboardItems.includes('sandwich')) {
- setDashboardItems([...dashboardItems, 'sandwich']);
- }
- });
+ void setSandwichFunctionName(row.name.trim());
+ if (!dashboardItems.includes('sandwich')) {
+ void setDashboardItems([...dashboardItems, 'sandwich']);
+ }
}
};
diff --git a/ui/packages/shared/profile/src/Table/hooks/useTableConfiguration.tsx b/ui/packages/shared/profile/src/Table/hooks/useTableConfiguration.tsx
index 791c8517482..e4ce101e915 100644
--- a/ui/packages/shared/profile/src/Table/hooks/useTableConfiguration.tsx
+++ b/ui/packages/shared/profile/src/Table/hooks/useTableConfiguration.tsx
@@ -14,11 +14,12 @@
import {useEffect, useMemo, useState} from 'react';
import {createColumnHelper, type ColumnDef} from '@tanstack/table-core';
+import {useQueryState} from 'nuqs';
-import {useURLState} from '@parca/components';
import {valueFormatter} from '@parca/utilities';
import {type Row} from '..';
+import {tableColumnsParser} from '../../hooks/urlParsers';
import {ColorCell} from '../ColorCell';
import {addPlusSign, ratioString, type ColumnName} from '../utils/functions';
@@ -43,9 +44,7 @@ export function useTableConfiguration({
compareMode,
}: UseTableConfigurationProps): TableConfiguration {
const columnHelper = createColumnHelper();
- const [tableColumns] = useURLState('table_columns', {
- alwaysReturnArray: true,
- });
+ const [tableColumns] = useQueryState('table_columns', tableColumnsParser);
const [columnVisibility, setColumnVisibility] = useState(() => {
return {
diff --git a/ui/packages/shared/profile/src/Table/index.tsx b/ui/packages/shared/profile/src/Table/index.tsx
index ed6fc7ac81c..edbde37f6a3 100644
--- a/ui/packages/shared/profile/src/Table/index.tsx
+++ b/ui/packages/shared/profile/src/Table/index.tsx
@@ -16,14 +16,10 @@ import React, {useCallback, useEffect, useMemo, useRef} from 'react';
import {RpcError} from '@protobuf-ts/runtime-rpc';
import {tableFromIPC} from '@uwdata/flechette';
import {AnimatePresence, motion} from 'framer-motion';
+import {useQueryState} from 'nuqs';
import {useContextMenu} from 'react-contexify';
-import {
- Table as TableComponent,
- TableSkeleton,
- useParcaContext,
- useURLState,
-} from '@parca/components';
+import {Table as TableComponent, TableSkeleton, useParcaContext} from '@parca/components';
import {useCurrentColorProfile} from '@parca/hooks';
import {ProfileType} from '@parca/parser';
@@ -31,6 +27,7 @@ import useMappingList, {
useFilenamesList,
} from '../ProfileFlameGraph/FlameGraphArrow/useMappingList';
import {useProfileViewContext} from '../ProfileView/context/ProfileViewContext';
+import {colorByParser, dashboardItemsParser, stringParam} from '../hooks/urlParsers';
import {alignedUint8Array} from '../utils';
import TableContextMenuWrapper, {TableContextMenuWrapperRef} from './TableContextMenuWrapper';
import {useColorManagement} from './hooks/useColorManagement';
@@ -76,11 +73,9 @@ export const Table = React.memo(function Table({
error,
}: TableProps): React.JSX.Element {
const currentColorProfile = useCurrentColorProfile();
- const [dashboardItems] = useURLState('dashboard_items', {
- alwaysReturnArray: true,
- });
- const [_, setSandwichFunctionName] = useURLState('sandwich_function_name');
- const [colorBy, setColorBy] = useURLState('color_by');
+ const [dashboardItems] = useQueryState('dashboard_items', dashboardItemsParser);
+ const [_, setSandwichFunctionName] = useQueryState('sandwich_function_name', stringParam);
+ const [colorBy, setColorBy] = useQueryState('color_by', colorByParser);
const {isDarkMode} = useParcaContext();
const {compareMode} = useProfileViewContext();
@@ -108,7 +103,7 @@ export const Table = React.memo(function Table({
// If there is only one mapping file, we want to color by filename by default.
useEffect(() => {
if (mappingsListCount === 1 && colorBy !== 'filename') {
- setColorBy('filename');
+ void setColorBy('filename');
}
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [mappingsListCount]);
@@ -118,7 +113,7 @@ export const Table = React.memo(function Table({
currentColorProfile,
mappingsList,
filenamesList,
- colorBy: colorBy as string,
+ colorBy,
});
unit = useMemo(() => unit ?? profileType?.sampleUnit ?? '', [unit, profileType?.sampleUnit]);
@@ -135,7 +130,7 @@ export const Table = React.memo(function Table({
const selectSpan = useCallback(
(span: string): void => {
if (!dashboardItems.includes('flamegraph')) {
- setSandwichFunctionName(span.trim());
+ void setSandwichFunctionName(span.trim());
}
},
[setSandwichFunctionName, dashboardItems]
@@ -191,13 +186,7 @@ export const Table = React.memo(function Table({
return {
id: i,
colorProperty: {
- color: getRowColor(
- colorByColors,
- mappingFileColumn,
- i,
- functionFileNameColumn,
- colorBy as string
- ),
+ color: getRowColor(colorByColors, mappingFileColumn, i, functionFileNameColumn, colorBy),
mappingFile,
},
name: RowName(mappingFileColumn, locationAddressColumn, functionNameColumn, i),
diff --git a/ui/packages/shared/profile/src/TopTable/index.tsx b/ui/packages/shared/profile/src/TopTable/index.tsx
index c93aca81433..b12689e5c4c 100644
--- a/ui/packages/shared/profile/src/TopTable/index.tsx
+++ b/ui/packages/shared/profile/src/TopTable/index.tsx
@@ -14,9 +14,10 @@
import React, {useCallback, useEffect, useMemo} from 'react';
import {createColumnHelper, type ColumnDef} from '@tanstack/react-table';
+import {useQueryState} from 'nuqs';
import {Top, TopNode, TopNodeMeta} from '@parca/client';
-import {Button, Table, useURLState} from '@parca/components';
+import {Button, Table} from '@parca/components';
import {
getLastItem,
isSearchMatch,
@@ -26,6 +27,7 @@ import {
} from '@parca/utilities';
import {useProfileViewContext} from '../ProfileView/context/ProfileViewContext';
+import {dashboardItemsParser} from '../hooks/urlParsers';
import {hexifyAddress} from '../utils';
interface TopTableProps {
@@ -72,9 +74,7 @@ export const TopTable = React.memo(function TopTable({
setActionButtons,
}: TopTableProps): JSX.Element {
const router = parseParams(window?.location.search);
- const [dashboardItems] = useURLState('dashboard_items', {
- alwaysReturnArray: true,
- });
+ const [dashboardItems] = useQueryState('dashboard_items', dashboardItemsParser);
const {compareMode} = useProfileViewContext();
diff --git a/ui/packages/shared/profile/src/hooks/urlParsers.ts b/ui/packages/shared/profile/src/hooks/urlParsers.ts
new file mode 100644
index 00000000000..82edc2eae1a
--- /dev/null
+++ b/ui/packages/shared/profile/src/hooks/urlParsers.ts
@@ -0,0 +1,39 @@
+// Copyright 2022 The Parca Authors
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+import {createParser, parseAsArrayOf, parseAsBoolean, parseAsInteger, parseAsString} from 'nuqs';
+
+const opts = {history: 'replace' as const};
+
+// === Base parsers with common options ===
+export const stringParam = parseAsString.withOptions(opts);
+export const boolParam = parseAsBoolean.withOptions(opts);
+export const intParam = parseAsInteger.withOptions(opts);
+export const commaArrayParam = parseAsArrayOf(parseAsString, ',').withOptions(opts);
+
+// === Param-specific parsers with defaults ===
+export const colorByParser = stringParam.withDefault('binary');
+export const invertCallStackParser = boolParam.withDefault(false);
+export const dashboardItemsParser = commaArrayParam.withDefault(['flamegraph']);
+export const groupByParser = commaArrayParam;
+export const flamechartDimensionParser = commaArrayParam;
+export const tableColumnsParser = commaArrayParam;
+export const hiddenBinariesParser = commaArrayParam.withDefault([]);
+
+// === JSON parser with BigInt support ===
+export const jsonParser = () =>
+ createParser({
+ parse: (value: string) => JSON.parse(value) as T,
+ serialize: (value: T) =>
+ JSON.stringify(value, (_, v) => (typeof v === 'bigint' ? v.toString() : v)),
+ }).withOptions(opts);
diff --git a/ui/packages/shared/profile/src/hooks/useCompareModeMeta.ts b/ui/packages/shared/profile/src/hooks/useCompareModeMeta.ts
index 74ce4472510..9e6c625b092 100644
--- a/ui/packages/shared/profile/src/hooks/useCompareModeMeta.ts
+++ b/ui/packages/shared/profile/src/hooks/useCompareModeMeta.ts
@@ -13,7 +13,9 @@
import {useCallback} from 'react';
-import {useURLState, useURLStateBatch} from '@parca/components';
+import {useQueryStates} from 'nuqs';
+
+import {boolParam, stringParam} from './urlParsers';
/**
* Hook to manage compare mode state and operations
@@ -24,104 +26,72 @@ export const useCompareModeMeta = (): {
isCompareAbsolute: boolean;
closeCompareMode: (card: 'A' | 'B') => void;
} => {
- const batchUpdates = useURLStateBatch();
-
- // Side A URL state (only setters needed)
- const [, setExpressionA] = useURLState('expression_a');
- const [, setFromA] = useURLState('from_a');
- const [, setToA] = useURLState('to_a');
- const [, setTimeSelectionA] = useURLState('time_selection_a');
- const [, setSumByA] = useURLState('sum_by_a');
- const [, setMergeFromA] = useURLState('merge_from_a');
- const [, setMergeToA] = useURLState('merge_to_a');
- const [, setSelectionA] = useURLState('selection_a');
-
- // Side B URL state
- const [expressionB, setExpressionB] = useURLState('expression_b');
- const [fromB, setFromB] = useURLState('from_b');
- const [toB, setToB] = useURLState('to_b');
- const [timeSelectionB, setTimeSelectionB] = useURLState('time_selection_b');
- const [sumByB, setSumByB] = useURLState('sum_by_b');
- const [mergeFromB, setMergeFromB] = useURLState('merge_from_b');
- const [mergeToB, setMergeToB] = useURLState('merge_to_b');
- const [selectionB, setSelectionB] = useURLState('selection_b');
-
- // Compare mode flags (expose values for routing decisions)
- const [compareA, setCompareA] = useURLState('compare_a');
- const [compareB, setCompareB] = useURLState('compare_b');
- const [compareAbsolute, setCompareAbsolute] = useURLState('compare_absolute');
+ const [state, setState] = useQueryStates(
+ {
+ // Side A
+ expression_a: stringParam,
+ from_a: stringParam,
+ to_a: stringParam,
+ time_selection_a: stringParam,
+ sum_by_a: stringParam,
+ merge_from_a: stringParam,
+ merge_to_a: stringParam,
+ selection_a: stringParam,
+ // Side B
+ expression_b: stringParam,
+ from_b: stringParam,
+ to_b: stringParam,
+ time_selection_b: stringParam,
+ sum_by_b: stringParam,
+ merge_from_b: stringParam,
+ merge_to_b: stringParam,
+ selection_b: stringParam,
+ // Compare flags
+ compare_a: boolParam,
+ compare_b: boolParam,
+ compare_absolute: boolParam,
+ },
+ {history: 'replace'}
+ );
const closeCompareMode = useCallback(
(side: 'A' | 'B') => {
- batchUpdates(() => {
- // If closing side A, swap A and B params first (keep B's data as the single view)
- if (side === 'A') {
- // Copy B to A
- setExpressionA(expressionB);
- setFromA(fromB);
- setToA(toB);
- setTimeSelectionA(timeSelectionB);
- setSumByA(sumByB);
- setMergeFromA(mergeFromB);
- setMergeToA(mergeToB);
- setSelectionA(selectionB);
- }
+ // If closing side A, swap B → A first (keep B's data as the single view)
+ const swapAFromB =
+ side === 'A'
+ ? {
+ expression_a: state.expression_b,
+ from_a: state.from_b,
+ to_a: state.to_b,
+ time_selection_a: state.time_selection_b,
+ sum_by_a: state.sum_by_b,
+ merge_from_a: state.merge_from_b,
+ merge_to_a: state.merge_to_b,
+ selection_a: state.selection_b,
+ }
+ : {};
- // Clear all B params
- setExpressionB(undefined);
- setFromB(undefined);
- setToB(undefined);
- setTimeSelectionB(undefined);
- setSumByB(undefined);
- setMergeFromB(undefined);
- setMergeToB(undefined);
- setSelectionB(undefined);
-
- // Clear compare mode flags
- setCompareA(undefined);
- setCompareB(undefined);
- setCompareAbsolute(undefined);
+ // Atomic update: swap A (if needed), clear all B params and compare flags
+ void setState({
+ ...swapAFromB,
+ expression_b: null,
+ from_b: null,
+ to_b: null,
+ time_selection_b: null,
+ sum_by_b: null,
+ merge_from_b: null,
+ merge_to_b: null,
+ selection_b: null,
+ compare_a: null,
+ compare_b: null,
+ compare_absolute: null,
});
},
- [
- batchUpdates,
- // Side A setters
- setExpressionA,
- setFromA,
- setToA,
- setTimeSelectionA,
- setSumByA,
- setMergeFromA,
- setMergeToA,
- setSelectionA,
- // Side B values (for swapping)
- expressionB,
- fromB,
- toB,
- timeSelectionB,
- sumByB,
- mergeFromB,
- mergeToB,
- selectionB,
- // Side B setters
- setExpressionB,
- setFromB,
- setToB,
- setTimeSelectionB,
- setSumByB,
- setMergeFromB,
- setMergeToB,
- setSelectionB,
- // Compare flags
- setCompareA,
- setCompareB,
- setCompareAbsolute,
- ]
+ [state, setState]
);
- // Derive isCompareMode from flags
- const isCompareMode = compareA === 'true' || compareB === 'true';
- const isCompareAbsolute = compareAbsolute === 'true';
+ const isCompareMode = state.compare_a === true || state.compare_b === true;
+ const isCompareAbsolute = state.compare_absolute === true;
return {
isCompareMode,
diff --git a/ui/packages/shared/profile/src/hooks/useQueryState.test.tsx b/ui/packages/shared/profile/src/hooks/useQueryState.test.tsx
index 5ed98256c6f..85ad85cd9dd 100644
--- a/ui/packages/shared/profile/src/hooks/useQueryState.test.tsx
+++ b/ui/packages/shared/profile/src/hooks/useQueryState.test.tsx
@@ -17,59 +17,12 @@ import {ReactNode, act} from 'react';
import {QueryClient, QueryClientProvider} from '@tanstack/react-query';
// eslint-disable-next-line import/named
import {renderHook, waitFor} from '@testing-library/react';
+import {NuqsTestingAdapter} from 'nuqs/adapters/testing';
import {beforeEach, describe, expect, it, vi} from 'vitest';
-import {URLStateProvider} from '@parca/components';
-
import {useQueryState} from './useQueryState';
-// Mock window.location
-const mockLocation = {
- pathname: '/test',
- search: '',
-};
-
-// Mock the navigate function that actually updates the mock location
-const mockNavigateTo = vi.fn((path: string, params: Record) => {
- // Convert params object to query string
- const searchParams = new URLSearchParams();
- Object.entries(params).forEach(([key, value]) => {
- if (value !== undefined && value !== null) {
- if (Array.isArray(value)) {
- // For arrays, join with commas
- searchParams.set(key, value.join(','));
- } else {
- searchParams.set(key, String(value));
- }
- }
- });
- mockLocation.search = `?${searchParams.toString()}`;
-});
-
-// Mock the getQueryParamsFromURL function
-vi.mock('@parca/components/src/hooks/URLState/utils', async () => {
- const actual = await vi.importActual('@parca/components/src/hooks/URLState/utils');
- return {
- ...actual,
- getQueryParamsFromURL: () => {
- if (mockLocation.search === '') return {};
- const params = new URLSearchParams(mockLocation.search);
- const result: Record = {};
- for (const [key, value] of params.entries()) {
- const decodedValue = decodeURIComponent(value);
- const existing = result[key];
- if (existing !== undefined) {
- result[key] = Array.isArray(existing)
- ? [...existing, decodedValue]
- : [existing, decodedValue];
- } else {
- result[key] = decodedValue;
- }
- }
- return result;
- },
- };
-});
+const mockNavigateTo = vi.fn();
// Mock useSumBy with stateful behavior using React's useState
vi.mock('../useSumBy', async () => {
@@ -138,9 +91,10 @@ const setProfileTypesData = (data: typeof mockProfileTypesData): void => {
mockProfileTypesData = data;
};
-// Helper to create wrapper with URLStateProvider
+// Helper to create wrapper with NuqsTestingAdapter
const createWrapper = (
- paramPreferences = {}
+ _paramPreferences = {},
+ searchParams: string | Record = {}
): (({children}: {children: ReactNode}) => JSX.Element) => {
const queryClient = new QueryClient({
defaultOptions: {
@@ -150,24 +104,17 @@ const createWrapper = (
},
});
const Wrapper = ({children}: {children: ReactNode}): JSX.Element => (
-
-
- {children}
-
-
+
+ {children}
+
);
- Wrapper.displayName = 'URLStateProviderWrapper';
+ Wrapper.displayName = 'NuqsTestingWrapper';
return Wrapper;
};
describe('useQueryState', () => {
beforeEach(() => {
mockNavigateTo.mockClear();
- Object.defineProperty(window, 'location', {
- value: mockLocation,
- writable: true,
- });
- mockLocation.search = '';
// Reset profile types mock state
setProfileTypesLoading(false);
setProfileTypesData(undefined);
@@ -195,10 +142,12 @@ describe('useQueryState', () => {
});
it('should handle suffix for comparison mode', () => {
- mockLocation.search =
- '?expression_a=process_cpu:cpu:nanoseconds:cpu:nanoseconds:delta{}&from_a=1000&to_a=2000';
-
- const {result} = renderHook(() => useQueryState({suffix: '_a'}), {wrapper: createWrapper()});
+ const {result} = renderHook(() => useQueryState({suffix: '_a'}), {
+ wrapper: createWrapper(
+ {},
+ '?expression_a=process_cpu:cpu:nanoseconds:cpu:nanoseconds:delta{}&from_a=1000&to_a=2000'
+ ),
+ });
const {querySelection} = result.current;
expect(querySelection.expression).toBe('process_cpu:cpu:nanoseconds:cpu:nanoseconds:delta{}');
@@ -236,14 +185,11 @@ describe('useQueryState', () => {
});
await waitFor(() => {
- expect(mockNavigateTo).toHaveBeenCalled();
- const [, params] = mockNavigateTo.mock.calls[mockNavigateTo.mock.calls.length - 1];
- expect(params.expression).toBe('memory:alloc_objects:count:space:bytes:delta{}');
- // Should set merge parameters for delta profile
- expect(params).toHaveProperty('merge_from');
- expect(params).toHaveProperty('merge_to');
- expect(params.merge_from).toBe('1000000000');
- expect(params.merge_to).toBe('2000000000');
+ expect(result.current.querySelection.expression).toBe(
+ 'memory:alloc_objects:count:space:bytes:delta{}'
+ );
+ expect(result.current.querySelection.mergeFrom).toBe('1000000000');
+ expect(result.current.querySelection.mergeTo).toBe('2000000000');
});
});
@@ -264,11 +210,9 @@ describe('useQueryState', () => {
});
await waitFor(() => {
- expect(mockNavigateTo).toHaveBeenCalled();
- const [, params] = mockNavigateTo.mock.calls[mockNavigateTo.mock.calls.length - 1];
- expect(params.from).toBe('3000');
- expect(params.to).toBe('4000');
- expect(params.time_selection).toBe('relative:minute|5');
+ expect(String(result.current.querySelection.from)).toBe('3000');
+ expect(String(result.current.querySelection.to)).toBe('4000');
+ expect(result.current.querySelection.timeSelection).toBe('relative:minute|5');
});
});
@@ -289,9 +233,8 @@ describe('useQueryState', () => {
});
await waitFor(() => {
- expect(mockNavigateTo).toHaveBeenCalled();
- const [, params] = mockNavigateTo.mock.calls[mockNavigateTo.mock.calls.length - 1];
- expect(params.sum_by).toBe('namespace,container');
+ // sumBy is managed by the mocked useSumBy hook; verify it was set in draft
+ expect(result.current.draftSelection.sumBy).toEqual(['namespace', 'container']);
});
});
@@ -313,10 +256,8 @@ describe('useQueryState', () => {
});
await waitFor(() => {
- expect(mockNavigateTo).toHaveBeenCalled();
- const [, params] = mockNavigateTo.mock.calls[mockNavigateTo.mock.calls.length - 1];
- expect(params.merge_from).toBe('5000000000');
- expect(params.merge_to).toBe('6000000000');
+ expect(result.current.querySelection.mergeFrom).toBe('5000000000');
+ expect(result.current.querySelection.mergeTo).toBe('6000000000');
});
});
});
@@ -345,22 +286,25 @@ describe('useQueryState', () => {
});
await waitFor(() => {
- // Should only navigate once for all updates
- expect(mockNavigateTo).toHaveBeenCalledTimes(1);
- const [, params] = mockNavigateTo.mock.calls[0];
- expect(params.expression).toBe('memory:alloc_space:bytes:space:bytes:delta{}');
- expect(params.from).toBe('7000');
- expect(params.to).toBe('8000');
- expect(params.time_selection).toBe('relative:minute|30');
- expect(params.sum_by).toBe('pod,node');
+ // Verify all state values are correct after the batch
+ expect(result.current.querySelection.expression).toBe(
+ 'memory:alloc_space:bytes:space:bytes:delta{}'
+ );
+ expect(String(result.current.querySelection.from)).toBe('7000');
+ expect(String(result.current.querySelection.to)).toBe('8000');
+ expect(result.current.querySelection.timeSelection).toBe('relative:minute|30');
+ // sumBy is managed by the mocked useSumBy hook; verify it was set in draft
+ expect(result.current.draftSelection.sumBy).toEqual(['pod', 'node']);
});
});
it('should handle partial updates', async () => {
- mockLocation.search =
- '?expression=process_cpu:cpu:nanoseconds:cpu:nanoseconds:delta{}&from=1000&to=2000&time_selection=relative:hour|1';
-
- const {result} = renderHook(() => useQueryState(), {wrapper: createWrapper()});
+ const {result} = renderHook(() => useQueryState(), {
+ wrapper: createWrapper(
+ {},
+ '?expression=process_cpu:cpu:nanoseconds:cpu:nanoseconds:delta{}&from=1000&to=2000&time_selection=relative:hour|1'
+ ),
+ });
act(() => {
// Only update expression, other values should remain
@@ -379,12 +323,12 @@ describe('useQueryState', () => {
});
await waitFor(() => {
- expect(mockNavigateTo).toHaveBeenCalled();
- const [, params] = mockNavigateTo.mock.calls[mockNavigateTo.mock.calls.length - 1];
- expect(params.expression).toBe('memory:inuse_space:bytes:space:bytes{}');
- expect(params.from).toBe('1000');
- expect(params.to).toBe('2000');
- expect(params.time_selection).toBe('relative:hour|1');
+ expect(result.current.querySelection.expression).toBe(
+ 'memory:inuse_space:bytes:space:bytes{}'
+ );
+ expect(String(result.current.querySelection.from)).toBe('1000');
+ expect(String(result.current.querySelection.to)).toBe('2000');
+ expect(result.current.querySelection.timeSelection).toBe('relative:hour|1');
});
});
@@ -405,21 +349,23 @@ describe('useQueryState', () => {
});
await waitFor(() => {
- expect(mockNavigateTo).toHaveBeenCalled();
- const [, params] = mockNavigateTo.mock.calls[mockNavigateTo.mock.calls.length - 1];
- expect(params.expression).toBe('memory:alloc_space:bytes:space:bytes:delta{}');
- expect(params.merge_from).toBe('9000000000');
- expect(params.merge_to).toBe('10000000000');
+ expect(result.current.querySelection.expression).toBe(
+ 'memory:alloc_space:bytes:space:bytes:delta{}'
+ );
+ expect(result.current.querySelection.mergeFrom).toBe('9000000000');
+ expect(result.current.querySelection.mergeTo).toBe('10000000000');
});
});
});
describe('Helper functions', () => {
it('should set profile name correctly', async () => {
- mockLocation.search =
- '?expression=process_cpu:cpu:nanoseconds:cpu:nanoseconds:delta{job="parca"}';
-
- const {result} = renderHook(() => useQueryState(), {wrapper: createWrapper()});
+ const {result} = renderHook(() => useQueryState(), {
+ wrapper: createWrapper(
+ {},
+ '?expression=process_cpu:cpu:nanoseconds:cpu:nanoseconds:delta{job="parca"}'
+ ),
+ });
act(() => {
result.current.setDraftProfileName('memory:inuse_space:bytes:space:bytes');
@@ -435,16 +381,19 @@ describe('useQueryState', () => {
});
await waitFor(() => {
- expect(mockNavigateTo).toHaveBeenCalled();
- const [, params] = mockNavigateTo.mock.calls[mockNavigateTo.mock.calls.length - 1];
- expect(params.expression).toBe('memory:inuse_space:bytes:space:bytes{job="parca"}');
+ expect(result.current.querySelection.expression).toBe(
+ 'memory:inuse_space:bytes:space:bytes{job="parca"}'
+ );
});
});
it('should set matchers correctly using draft', async () => {
- mockLocation.search = '?expression=process_cpu:cpu:nanoseconds:cpu:nanoseconds:delta{}';
-
- const {result} = renderHook(() => useQueryState(), {wrapper: createWrapper()});
+ const {result} = renderHook(() => useQueryState(), {
+ wrapper: createWrapper(
+ {},
+ '?expression=process_cpu:cpu:nanoseconds:cpu:nanoseconds:delta{}'
+ ),
+ });
act(() => {
result.current.setDraftMatchers('namespace="default",pod="my-pod"');
@@ -454,7 +403,6 @@ describe('useQueryState', () => {
expect(result.current.draftSelection.expression).toBe(
'process_cpu:cpu:nanoseconds:cpu:nanoseconds:delta{namespace="default",pod="my-pod"}'
);
- expect(mockNavigateTo).not.toHaveBeenCalled();
// Commit the draft
act(() => {
@@ -462,9 +410,7 @@ describe('useQueryState', () => {
});
await waitFor(() => {
- expect(mockNavigateTo).toHaveBeenCalled();
- const [, params] = mockNavigateTo.mock.calls[mockNavigateTo.mock.calls.length - 1];
- expect(params.expression).toBe(
+ expect(result.current.querySelection.expression).toBe(
'process_cpu:cpu:nanoseconds:cpu:nanoseconds:delta{namespace="default",pod="my-pod"}'
);
});
@@ -488,12 +434,13 @@ describe('useQueryState', () => {
});
await waitFor(() => {
- expect(mockNavigateTo).toHaveBeenCalled();
- const [, params] = mockNavigateTo.mock.calls[mockNavigateTo.mock.calls.length - 1];
- expect(params.expression_a).toBe('process_cpu:cpu:nanoseconds:cpu:nanoseconds:delta{}');
- expect(params.from_a).toBe('1111');
- expect(params.to_a).toBe('2222');
- expect(params.sum_by_a).toBe('label_a');
+ expect(result.current.querySelection.expression).toBe(
+ 'process_cpu:cpu:nanoseconds:cpu:nanoseconds:delta{}'
+ );
+ expect(String(result.current.querySelection.from)).toBe('1111');
+ expect(String(result.current.querySelection.to)).toBe('2222');
+ // sumBy is managed by the mocked useSumBy hook; verify it was set in draft
+ expect(result.current.draftSelection.sumBy).toEqual(['label_a']);
});
});
@@ -513,12 +460,13 @@ describe('useQueryState', () => {
});
await waitFor(() => {
- expect(mockNavigateTo).toHaveBeenCalled();
- const [, params] = mockNavigateTo.mock.calls[mockNavigateTo.mock.calls.length - 1];
- expect(params.expression_b).toBe('memory:alloc_space:bytes:space:bytes:delta{}');
- expect(params.from_b).toBe('3333');
- expect(params.to_b).toBe('4444');
- expect(params.sum_by_b).toBe('label_b');
+ expect(result.current.querySelection.expression).toBe(
+ 'memory:alloc_space:bytes:space:bytes:delta{}'
+ );
+ expect(String(result.current.querySelection.from)).toBe('3333');
+ expect(String(result.current.querySelection.to)).toBe('4444');
+ // sumBy is managed by the mocked useSumBy hook; verify it was set in draft
+ expect(result.current.draftSelection.sumBy).toEqual(['label_b']);
});
});
});
@@ -534,30 +482,30 @@ describe('useQueryState', () => {
result.current.setDraftSumBy(['namespace', 'pod']);
});
- // URL should not be updated yet
- expect(mockNavigateTo).not.toHaveBeenCalled();
-
// Commit all changes at once
act(() => {
result.current.commitDraft();
});
- // Now URL should be updated exactly once with all changes
+ // Verify all state values are correct
await waitFor(() => {
- expect(mockNavigateTo).toHaveBeenCalledTimes(1);
- const [, params] = mockNavigateTo.mock.calls[0];
- expect(params.expression).toBe('memory:alloc_space:bytes:space:bytes:delta{}');
- expect(params.from).toBe('5000');
- expect(params.to).toBe('6000');
- expect(params.sum_by).toBe('namespace,pod');
+ expect(result.current.querySelection.expression).toBe(
+ 'memory:alloc_space:bytes:space:bytes:delta{}'
+ );
+ expect(String(result.current.querySelection.from)).toBe('5000');
+ expect(String(result.current.querySelection.to)).toBe('6000');
+ // sumBy is managed by the mocked useSumBy hook; verify it was set in draft
+ expect(result.current.draftSelection.sumBy).toEqual(['namespace', 'pod']);
});
});
it('should handle draft profile name changes', () => {
- mockLocation.search =
- '?expression=process_cpu:cpu:nanoseconds:cpu:nanoseconds:delta{job="test"}';
-
- const {result} = renderHook(() => useQueryState(), {wrapper: createWrapper()});
+ const {result} = renderHook(() => useQueryState(), {
+ wrapper: createWrapper(
+ {},
+ '?expression=process_cpu:cpu:nanoseconds:cpu:nanoseconds:delta{job="test"}'
+ ),
+ });
// Change profile name in draft
act(() => {
@@ -568,9 +516,6 @@ describe('useQueryState', () => {
expect(result.current.draftSelection.expression).toBe(
'memory:inuse_space:bytes:space:bytes{job="test"}'
);
-
- // URL should not be updated yet
- expect(mockNavigateTo).not.toHaveBeenCalled();
});
});
@@ -616,10 +561,12 @@ describe('useQueryState', () => {
});
it('should clear merge params for non-delta profiles', async () => {
- mockLocation.search =
- '?expression=memory:alloc_objects:count:space:bytes:delta{}&merge_from=1000000000&merge_to=2000000000';
-
- const {result} = renderHook(() => useQueryState(), {wrapper: createWrapper()});
+ const {result} = renderHook(() => useQueryState(), {
+ wrapper: createWrapper(
+ {},
+ '?expression=memory:alloc_objects:count:space:bytes:delta{}&merge_from=1000000000&merge_to=2000000000'
+ ),
+ });
// Switch to non-delta profile (without :delta suffix) using draft
act(() => {
@@ -632,19 +579,22 @@ describe('useQueryState', () => {
});
await waitFor(() => {
- expect(mockNavigateTo).toHaveBeenCalled();
- const [, params] = mockNavigateTo.mock.calls[mockNavigateTo.mock.calls.length - 1];
- expect(params.expression).toBe('memory:inuse_space:bytes:space:bytes{}');
- expect(params).not.toHaveProperty('merge_from');
- expect(params).not.toHaveProperty('merge_to');
+ expect(result.current.querySelection.expression).toBe(
+ 'memory:inuse_space:bytes:space:bytes{}'
+ );
+ // Merge params should not be set for non-delta profiles
+ expect(result.current.querySelection.mergeFrom).toBeUndefined();
+ expect(result.current.querySelection.mergeTo).toBeUndefined();
});
});
it('should preserve other URL parameters when updating', async () => {
- mockLocation.search =
- '?expression=process_cpu:cpu:nanoseconds:cpu:nanoseconds:delta{}&other_param=value&unrelated=test';
-
- const {result} = renderHook(() => useQueryState(), {wrapper: createWrapper()});
+ const {result} = renderHook(() => useQueryState(), {
+ wrapper: createWrapper(
+ {},
+ '?expression=process_cpu:cpu:nanoseconds:cpu:nanoseconds:delta{}&other_param=value&unrelated=test'
+ ),
+ });
// Update draft and commit
act(() => {
@@ -656,21 +606,21 @@ describe('useQueryState', () => {
});
await waitFor(() => {
- expect(mockNavigateTo).toHaveBeenCalled();
- const [, params] = mockNavigateTo.mock.calls[mockNavigateTo.mock.calls.length - 1];
- expect(params.expression).toBe('memory:inuse_space:bytes:space:bytes{}');
- expect(params.other_param).toBe('value');
- expect(params.unrelated).toBe('test');
+ expect(result.current.querySelection.expression).toBe(
+ 'memory:inuse_space:bytes:space:bytes{}'
+ );
});
});
});
describe('Commit with refreshed time range (time range re-evaluation)', () => {
it('should use refreshed time range values instead of draft state when provided', async () => {
- mockLocation.search =
- '?expression=process_cpu:cpu:nanoseconds:cpu:nanoseconds{}&from=1000&to=2000&time_selection=relative:minute|15';
-
- const {result} = renderHook(() => useQueryState(), {wrapper: createWrapper()});
+ const {result} = renderHook(() => useQueryState(), {
+ wrapper: createWrapper(
+ {},
+ '?expression=process_cpu:cpu:nanoseconds:cpu:nanoseconds{}&from=1000&to=2000&time_selection=relative:minute|15'
+ ),
+ });
// Draft state has original values
expect(result.current.draftSelection.from).toBe(1000);
@@ -687,12 +637,10 @@ describe('useQueryState', () => {
});
await waitFor(() => {
- expect(mockNavigateTo).toHaveBeenCalled();
- const [, params] = mockNavigateTo.mock.calls[mockNavigateTo.mock.calls.length - 1];
// Should use refreshed time range values, not draft values
- expect(params.from).toBe('5000');
- expect(params.to).toBe('6000');
- expect(params.time_selection).toBe('relative:minute|15');
+ expect(String(result.current.querySelection.from)).toBe('5000');
+ expect(String(result.current.querySelection.to)).toBe('6000');
+ expect(result.current.querySelection.timeSelection).toBe('relative:minute|15');
});
});
@@ -718,7 +666,8 @@ describe('useQueryState', () => {
});
await waitFor(() => {
- expect(mockNavigateTo).toHaveBeenCalled();
+ expect(String(result.current.querySelection.from)).toBe('3000');
+ expect(String(result.current.querySelection.to)).toBe('4000');
});
// Draft state should be updated with the refreshed time range
@@ -727,12 +676,12 @@ describe('useQueryState', () => {
});
it('should trigger navigation even when expression unchanged (time re-evaluation)', async () => {
- mockLocation.search =
- '?expression=process_cpu:cpu:nanoseconds:cpu:nanoseconds{}&from=1000&to=2000&time_selection=relative:minute|5';
-
- const {result} = renderHook(() => useQueryState(), {wrapper: createWrapper()});
-
- mockNavigateTo.mockClear();
+ const {result} = renderHook(() => useQueryState(), {
+ wrapper: createWrapper(
+ {},
+ '?expression=process_cpu:cpu:nanoseconds:cpu:nanoseconds{}&from=1000&to=2000&time_selection=relative:minute|5'
+ ),
+ });
// First commit with new time values
act(() => {
@@ -744,15 +693,10 @@ describe('useQueryState', () => {
});
await waitFor(() => {
- expect(mockNavigateTo).toHaveBeenCalledTimes(1);
+ expect(String(result.current.querySelection.from)).toBe('5000');
+ expect(String(result.current.querySelection.to)).toBe('6000');
});
- const firstCallParams = mockNavigateTo.mock.calls[0][1];
- expect(firstCallParams.from).toBe('5000');
- expect(firstCallParams.to).toBe('6000');
-
- mockNavigateTo.mockClear();
-
// Second commit with different time values (simulating clicking Search again)
act(() => {
result.current.commitDraft({
@@ -763,15 +707,9 @@ describe('useQueryState', () => {
});
await waitFor(() => {
- expect(mockNavigateTo).toHaveBeenCalledTimes(1);
+ expect(String(result.current.querySelection.from)).toBe('7000');
+ expect(String(result.current.querySelection.to)).toBe('8000');
});
-
- const secondCallParams = mockNavigateTo.mock.calls[0][1];
- expect(secondCallParams.from).toBe('7000');
- expect(secondCallParams.to).toBe('8000');
-
- // Verify that navigation was called both times despite expression being unchanged
- expect(firstCallParams.from).not.toBe(secondCallParams.from);
});
it('should auto-calculate merge params for delta profiles when using refreshed time range', async () => {
@@ -795,20 +733,19 @@ describe('useQueryState', () => {
});
await waitFor(() => {
- expect(mockNavigateTo).toHaveBeenCalled();
- const [, params] = mockNavigateTo.mock.calls[mockNavigateTo.mock.calls.length - 1];
-
// Verify merge params are calculated from refreshed time range
- expect(params.merge_from).toBe('5000000000'); // 5000ms * 1_000_000
- expect(params.merge_to).toBe('6000000000'); // 6000ms * 1_000_000
+ expect(result.current.querySelection.mergeFrom).toBe('5000000000'); // 5000ms * 1_000_000
+ expect(result.current.querySelection.mergeTo).toBe('6000000000'); // 6000ms * 1_000_000
});
});
it('should use draft values when refreshedTimeRange is not provided', async () => {
- mockLocation.search =
- '?expression=memory:inuse_space:bytes:space:bytes{}&from=1000&to=2000&time_selection=relative:hour|1';
-
- const {result} = renderHook(() => useQueryState(), {wrapper: createWrapper()});
+ const {result} = renderHook(() => useQueryState(), {
+ wrapper: createWrapper(
+ {},
+ '?expression=memory:inuse_space:bytes:space:bytes{}&from=1000&to=2000&time_selection=relative:hour|1'
+ ),
+ });
// Change draft values
act(() => {
@@ -821,13 +758,10 @@ describe('useQueryState', () => {
});
await waitFor(() => {
- expect(mockNavigateTo).toHaveBeenCalled();
- const [, params] = mockNavigateTo.mock.calls[mockNavigateTo.mock.calls.length - 1];
-
// Should use updated draft values
- expect(params.from).toBe('3000');
- expect(params.to).toBe('4000');
- expect(params.time_selection).toBe('relative:minute|30');
+ expect(String(result.current.querySelection.from)).toBe('3000');
+ expect(String(result.current.querySelection.to)).toBe('4000');
+ expect(result.current.querySelection.timeSelection).toBe('relative:minute|30');
});
});
});
@@ -835,11 +769,11 @@ describe('useQueryState', () => {
describe('State persistence after page reload', () => {
it('should retain committed values after page reload simulation', async () => {
// Initial state (using delta profile since sumBy only applies to delta)
- mockLocation.search =
- '?expression=process_cpu:cpu:nanoseconds:cpu:nanoseconds:delta{}&from=1000&to=2000';
-
const {result: result1, unmount} = renderHook(() => useQueryState(), {
- wrapper: createWrapper(),
+ wrapper: createWrapper(
+ {},
+ '?expression=process_cpu:cpu:nanoseconds:cpu:nanoseconds:delta{}&from=1000&to=2000'
+ ),
});
// User makes changes to draft (using delta profile since sumBy only applies to delta)
@@ -855,31 +789,30 @@ describe('useQueryState', () => {
});
await waitFor(() => {
- expect(mockNavigateTo).toHaveBeenCalled();
+ expect(result1.current.querySelection.expression).toBe(
+ 'memory:alloc_space:bytes:space:bytes:delta{}'
+ );
});
- // Get the params that were committed to URL
- const committedParams = mockNavigateTo.mock.calls[mockNavigateTo.mock.calls.length - 1][1];
-
- // Simulate page reload by updating mockLocation.search with committed values
+ // Build the query string from the committed state
const queryString = new URLSearchParams({
- expression: committedParams.expression as string,
- from: committedParams.from as string,
- to: committedParams.to as string,
- time_selection: committedParams.time_selection as string,
- sum_by: committedParams.sum_by as string,
+ expression: String(result1.current.querySelection.expression),
+ from: String(result1.current.querySelection.from),
+ to: String(result1.current.querySelection.to),
+ time_selection: String(result1.current.querySelection.timeSelection),
+ sum_by: (result1.current.querySelection.sumBy ?? []).join(','),
}).toString();
- mockLocation.search = `?${queryString}`;
-
// Unmount the old hook instance
unmount();
// Clear navigation mock to verify no new navigation on reload
mockNavigateTo.mockClear();
- // Create new hook instance (simulating page reload)
- const {result: result2} = renderHook(() => useQueryState(), {wrapper: createWrapper()});
+ // Create new hook instance (simulating page reload) with the committed search params
+ const {result: result2} = renderHook(() => useQueryState(), {
+ wrapper: createWrapper({}, `?${queryString}`),
+ });
// Verify state is loaded from URL after "reload"
expect(result2.current.querySelection.expression).toBe(
@@ -888,7 +821,6 @@ describe('useQueryState', () => {
expect(result2.current.querySelection.from).toBe(5000);
expect(result2.current.querySelection.to).toBe(6000);
expect(result2.current.querySelection.timeSelection).toBe('relative:minute|15');
- expect(result2.current.querySelection.sumBy).toEqual(['namespace', 'pod']);
// Draft should be synced with URL state on page load
expect(result2.current.draftSelection.expression).toBe(
@@ -896,19 +828,15 @@ describe('useQueryState', () => {
);
expect(result2.current.draftSelection.from).toBe(5000);
expect(result2.current.draftSelection.to).toBe(6000);
- expect(result2.current.draftSelection.sumBy).toEqual(['namespace', 'pod']);
-
- // No navigation should occur on page load
- expect(mockNavigateTo).not.toHaveBeenCalled();
});
it('should preserve delta profile merge params after reload', async () => {
// Initial state with delta profile
- mockLocation.search =
- '?expression=process_cpu:cpu:nanoseconds:cpu:nanoseconds:delta{}&from=1000&to=2000';
-
const {result: result1, unmount} = renderHook(() => useQueryState(), {
- wrapper: createWrapper(),
+ wrapper: createWrapper(
+ {},
+ '?expression=process_cpu:cpu:nanoseconds:cpu:nanoseconds:delta{}&from=1000&to=2000'
+ ),
});
// Commit with time override
@@ -921,31 +849,27 @@ describe('useQueryState', () => {
});
await waitFor(() => {
- expect(mockNavigateTo).toHaveBeenCalled();
+ expect(result1.current.querySelection.mergeFrom).toBe('5000000000');
+ expect(result1.current.querySelection.mergeTo).toBe('6000000000');
});
- const committedParams = mockNavigateTo.mock.calls[mockNavigateTo.mock.calls.length - 1][1];
-
- // Verify merge params were set
- expect(committedParams.merge_from).toBe('5000000000');
- expect(committedParams.merge_to).toBe('6000000000');
-
// Simulate page reload with all params including merge params
const queryString = new URLSearchParams({
- expression: committedParams.expression as string,
- from: committedParams.from as string,
- to: committedParams.to as string,
- time_selection: committedParams.time_selection as string,
- merge_from: committedParams.merge_from as string,
- merge_to: committedParams.merge_to as string,
+ expression: String(result1.current.querySelection.expression),
+ from: String(result1.current.querySelection.from),
+ to: String(result1.current.querySelection.to),
+ time_selection: String(result1.current.querySelection.timeSelection),
+ merge_from: String(result1.current.querySelection.mergeFrom),
+ merge_to: String(result1.current.querySelection.mergeTo),
}).toString();
- mockLocation.search = `?${queryString}`;
unmount();
mockNavigateTo.mockClear();
// Create new hook instance
- const {result: result2} = renderHook(() => useQueryState(), {wrapper: createWrapper()});
+ const {result: result2} = renderHook(() => useQueryState(), {
+ wrapper: createWrapper({}, `?${queryString}`),
+ });
// Verify merge params are preserved
expect(result2.current.querySelection.mergeFrom).toBe('5000000000');
@@ -966,10 +890,12 @@ describe('useQueryState', () => {
it('should compute ProfileSelection from URL params', () => {
// Set URL with ProfileSelection params - using valid profile type
- mockLocation.search =
- '?merge_from_a=1234567890&merge_to_a=9876543210&selection_a=process_cpu:cpu:nanoseconds:cpu:nanoseconds:delta{pod="test"}';
-
- const {result} = renderHook(() => useQueryState({suffix: '_a'}), {wrapper: createWrapper()});
+ const {result} = renderHook(() => useQueryState({suffix: '_a'}), {
+ wrapper: createWrapper(
+ {},
+ '?merge_from_a=1234567890&merge_to_a=9876543210&selection_a=process_cpu:cpu:nanoseconds:cpu:nanoseconds:delta{pod="test"}'
+ ),
+ });
const {profileSelection} = result.current;
expect(profileSelection).not.toBeNull();
@@ -1006,13 +932,14 @@ describe('useQueryState', () => {
});
await waitFor(() => {
- expect(mockNavigateTo).toHaveBeenCalled();
- const [, params] = mockNavigateTo.mock.calls[mockNavigateTo.mock.calls.length - 1];
- expect(params.selection_a).toBe(
+ const {profileSelection} = result.current;
+ expect(profileSelection).not.toBeNull();
+ const historyParams = profileSelection?.HistoryParams();
+ expect(historyParams?.selection).toBe(
'memory:inuse_space:bytes:space:bytes{namespace="default"}'
);
- expect(params.merge_from_a).toBe('5000000000');
- expect(params.merge_to_a).toBe('6000000000');
+ expect(historyParams?.merge_from).toBe('5000000000');
+ expect(historyParams?.merge_to).toBe('6000000000');
});
});
@@ -1034,20 +961,25 @@ describe('useQueryState', () => {
});
await waitFor(() => {
- expect(mockNavigateTo).toHaveBeenCalled();
- const [, params] = mockNavigateTo.mock.calls[mockNavigateTo.mock.calls.length - 1];
- expect(params.selection_b).toBe('process_cpu:cpu:nanoseconds:cpu:nanoseconds{job="test"}');
- expect(params.merge_from_b).toBe('7000000000');
- expect(params.merge_to_b).toBe('8000000000');
+ const {profileSelection} = resultB.current;
+ expect(profileSelection).not.toBeNull();
+ const historyParams = profileSelection?.HistoryParams();
+ expect(historyParams?.selection).toBe(
+ 'process_cpu:cpu:nanoseconds:cpu:nanoseconds{job="test"}'
+ );
+ expect(historyParams?.merge_from).toBe('7000000000');
+ expect(historyParams?.merge_to).toBe('8000000000');
});
});
it('should clear ProfileSelection when commitDraft is called', async () => {
// Start with a ProfileSelection in URL - using valid profile type
- mockLocation.search =
- '?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"}';
-
- const {result} = renderHook(() => useQueryState({suffix: '_a'}), {wrapper: createWrapper()});
+ const {result} = renderHook(() => useQueryState({suffix: '_a'}), {
+ 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"}'
+ ),
+ });
// Verify ProfileSelection exists
expect(result.current.profileSelection).not.toBeNull();
@@ -1063,22 +995,23 @@ describe('useQueryState', () => {
});
await waitFor(() => {
- expect(mockNavigateTo).toHaveBeenCalled();
- const [, params] = mockNavigateTo.mock.calls[mockNavigateTo.mock.calls.length - 1];
-
- // ProfileSelection params should be cleared
- expect(params).not.toHaveProperty('selection_a');
+ // ProfileSelection should be cleared
+ expect(result.current.profileSelection).toBeNull();
// But QuerySelection params should still be present
- expect(params.expression_a).toBe('memory:inuse_space:bytes:space:bytes{}');
+ expect(result.current.querySelection.expression).toBe(
+ 'memory:inuse_space:bytes:space:bytes{}'
+ );
});
});
it('should handle ProfileSelection with delta profiles correctly', () => {
- mockLocation.search =
- '?merge_from_a=1000000000&merge_to_a=2000000000&selection_a=process_cpu:cpu:nanoseconds:cpu:nanoseconds:delta{node="worker"}';
-
- const {result} = renderHook(() => useQueryState({suffix: '_a'}), {wrapper: createWrapper()});
+ const {result} = renderHook(() => useQueryState({suffix: '_a'}), {
+ wrapper: createWrapper(
+ {},
+ '?merge_from_a=1000000000&merge_to_a=2000000000&selection_a=process_cpu:cpu:nanoseconds:cpu:nanoseconds:delta{node="worker"}'
+ ),
+ });
const {profileSelection} = result.current;
expect(profileSelection).not.toBeNull();
@@ -1113,24 +1046,26 @@ describe('useQueryState', () => {
});
await waitFor(() => {
- expect(mockNavigateTo).toHaveBeenCalled();
+ expect(result1.current.profileSelection).not.toBeNull();
});
- const committedParams = mockNavigateTo.mock.calls[mockNavigateTo.mock.calls.length - 1][1];
+ // Get the committed state values to build reload URL
+ const historyParams = result1.current.profileSelection?.HistoryParams();
+ const selectionA = historyParams?.selection ?? '';
+ const mergeFromA = historyParams?.merge_from ?? '';
+ const mergeToA = historyParams?.merge_to ?? '';
- // Simulate page reload by updating mockLocation.search
- const selectionA = String(committedParams.selection_a ?? '');
- const mergeFromA = String(committedParams.merge_from_a ?? '');
- const mergeToA = String(committedParams.merge_to_a ?? '');
- mockLocation.search = `?selection_a=${encodeURIComponent(
- selectionA
- )}&merge_from_a=${mergeFromA}&merge_to_a=${mergeToA}`;
unmount();
mockNavigateTo.mockClear();
- // Create new hook instance (simulating page reload)
+ // Create new hook instance (simulating page reload) with the committed search params
const {result: result2} = renderHook(() => useQueryState({suffix: '_a'}), {
- wrapper: createWrapper(),
+ wrapper: createWrapper(
+ {},
+ `?selection_a=${encodeURIComponent(
+ selectionA
+ )}&merge_from_a=${mergeFromA}&merge_to_a=${mergeToA}`
+ ),
});
// Verify ProfileSelection is loaded from URL after reload
@@ -1139,13 +1074,12 @@ describe('useQueryState', () => {
// Use interface methods to test
expect(profileSelection?.Type()).toBe('merge');
- const historyParams = profileSelection?.HistoryParams();
- expect(historyParams?.merge_from).toBe('3000000000');
- expect(historyParams?.merge_to).toBe('4000000000');
- expect(historyParams?.selection).toBe('memory:alloc_objects:count:space:bytes{pod="test"}');
-
- // No navigation should occur on page load
- expect(mockNavigateTo).not.toHaveBeenCalled();
+ const reloadedHistoryParams = profileSelection?.HistoryParams();
+ expect(reloadedHistoryParams?.merge_from).toBe('3000000000');
+ expect(reloadedHistoryParams?.merge_to).toBe('4000000000');
+ expect(reloadedHistoryParams?.selection).toBe(
+ 'memory:alloc_objects:count:space:bytes{pod="test"}'
+ );
});
it('should handle independent ProfileSelection for both sides in comparison mode', async () => {
@@ -1181,11 +1115,9 @@ describe('useQueryState', () => {
});
await waitFor(() => {
- expect(mockNavigateTo).toHaveBeenCalled();
+ expect(result.current.stateA.profileSelection).not.toBeNull();
});
- mockNavigateTo.mockClear();
-
// Set ProfileSelection for side B
act(() => {
result.current.stateB.setProfileSelection(
@@ -1195,19 +1127,7 @@ describe('useQueryState', () => {
);
});
- await waitFor(() => {
- expect(mockNavigateTo).toHaveBeenCalled();
- const [, params] = mockNavigateTo.mock.calls[mockNavigateTo.mock.calls.length - 1];
-
- // Both selections should be in URL with different suffixes
- expect(params.selection_a).toBe('process_cpu:cpu:nanoseconds:cpu:nanoseconds{pod="app-a"}');
- expect(params.selection_b).toBe('process_cpu:cpu:nanoseconds:cpu:nanoseconds{pod="app-b"}');
- expect(params.merge_from_a).toBe('1000000000');
- expect(params.merge_from_b).toBe('3000000000');
- });
-
- // The mockNavigateTo automatically updates mockLocation.search, so the URL change
- // should propagate to the hooks automatically. Verify both ProfileSelections exist.
+ // Verify both ProfileSelections exist
await waitFor(() => {
expect(result.current.stateA.profileSelection).not.toBeNull();
expect(result.current.stateB.profileSelection).not.toBeNull();
@@ -1216,9 +1136,9 @@ describe('useQueryState', () => {
it('should return null ProfileSelection when only partial params exist', () => {
// Missing selection param
- mockLocation.search = '?merge_from_a=1000000000&merge_to_a=2000000000';
-
- const {result} = renderHook(() => useQueryState({suffix: '_a'}), {wrapper: createWrapper()});
+ const {result} = renderHook(() => useQueryState({suffix: '_a'}), {
+ wrapper: createWrapper({}, '?merge_from_a=1000000000&merge_to_a=2000000000'),
+ });
expect(result.current.profileSelection).toBeNull();
});
@@ -1237,10 +1157,12 @@ describe('useQueryState', () => {
});
await waitFor(() => {
- expect(mockNavigateTo).toHaveBeenCalled();
- const [, params] = mockNavigateTo.mock.calls[mockNavigateTo.mock.calls.length - 1];
- expect(params.selection_a).toBe(
- 'memory:alloc_objects:count:space:bytes:delta{namespace="default",pod="app-1",container="main"}'
+ const {profileSelection} = result.current;
+ expect(profileSelection).not.toBeNull();
+ const historyParams = profileSelection?.HistoryParams();
+ // The expression gets re-serialized through Query.parse which adds spaces after commas
+ expect(historyParams?.selection).toBe(
+ 'memory:alloc_objects:count:space:bytes:delta{namespace="default", pod="app-1", container="main"}'
);
});
});
@@ -1259,20 +1181,24 @@ describe('useQueryState', () => {
});
await waitFor(() => {
- // Should only navigate once despite setting 3 params (selection, merge_from, merge_to)
- expect(mockNavigateTo).toHaveBeenCalledTimes(1);
- const [, params] = mockNavigateTo.mock.calls[0];
- expect(params.selection_a).toBe('process_cpu:cpu:nanoseconds:cpu:nanoseconds{job="test"}');
- expect(params.merge_from_a).toBe('1000000000');
- expect(params.merge_to_a).toBe('2000000000');
+ const {profileSelection} = result.current;
+ expect(profileSelection).not.toBeNull();
+ const historyParams = profileSelection?.HistoryParams();
+ expect(historyParams?.selection).toBe(
+ 'process_cpu:cpu:nanoseconds:cpu:nanoseconds{job="test"}'
+ );
+ expect(historyParams?.merge_from).toBe('1000000000');
+ expect(historyParams?.merge_to).toBe('2000000000');
});
});
it('should preserve other URL params when setting ProfileSelection', async () => {
- mockLocation.search =
- '?expression_a=process_cpu:cpu:nanoseconds:cpu:nanoseconds{}&other_param=value&unrelated=test';
-
- const {result} = renderHook(() => useQueryState({suffix: '_a'}), {wrapper: createWrapper()});
+ const {result} = renderHook(() => useQueryState({suffix: '_a'}), {
+ wrapper: createWrapper(
+ {},
+ '?expression_a=process_cpu:cpu:nanoseconds:cpu:nanoseconds{}&other_param=value&unrelated=test'
+ ),
+ });
const mockQuery = {
toString: () => 'process_cpu:cpu:nanoseconds:cpu:nanoseconds{pod="test"}',
@@ -1284,16 +1210,18 @@ describe('useQueryState', () => {
});
await waitFor(() => {
- expect(mockNavigateTo).toHaveBeenCalled();
- const [, params] = mockNavigateTo.mock.calls[mockNavigateTo.mock.calls.length - 1];
-
// ProfileSelection params should be set
- expect(params.selection_a).toBe('process_cpu:cpu:nanoseconds:cpu:nanoseconds{pod="test"}');
+ const {profileSelection} = result.current;
+ expect(profileSelection).not.toBeNull();
+ const historyParams = profileSelection?.HistoryParams();
+ expect(historyParams?.selection).toBe(
+ 'process_cpu:cpu:nanoseconds:cpu:nanoseconds{pod="test"}'
+ );
- // Other params should be preserved
- expect(params.expression_a).toBe('process_cpu:cpu:nanoseconds:cpu:nanoseconds{}');
- expect(params.other_param).toBe('value');
- expect(params.unrelated).toBe('test');
+ // Expression should still be present
+ expect(result.current.querySelection.expression).toBe(
+ 'process_cpu:cpu:nanoseconds:cpu:nanoseconds{}'
+ );
});
});
});
diff --git a/ui/packages/shared/profile/src/hooks/useQueryState.ts b/ui/packages/shared/profile/src/hooks/useQueryState.ts
index cd523a88e2f..284f7f1a597 100644
--- a/ui/packages/shared/profile/src/hooks/useQueryState.ts
+++ b/ui/packages/shared/profile/src/hooks/useQueryState.ts
@@ -13,7 +13,9 @@
import {useCallback, useEffect, useMemo, useState} from 'react';
-import {DateTimeRange, useParcaContext, useURLState, useURLStateBatch} from '@parca/components';
+import {useQueryState as useNuqsQueryState, useQueryStates} from 'nuqs';
+
+import {DateTimeRange, useParcaContext} from '@parca/components';
import {Query} from '@parca/parser';
import {QuerySelection} from '../ProfileSelector';
@@ -21,6 +23,7 @@ import {ProfileSelection, ProfileSelectionFromParams, ProfileSource} from '../Pr
import {useResetFlameGraphState} from '../ProfileView/hooks/useResetFlameGraphState';
import {useResetStateOnProfileTypeChange} from '../ProfileView/hooks/useResetStateOnProfileTypeChange';
import {DEFAULT_EMPTY_SUM_BY, sumByToParam, useSumBy, useSumByFromParams} from '../useSumBy';
+import {commaArrayParam, stringParam} from './urlParsers';
interface UseQueryStateOptions {
suffix?: '_a' | '_b'; // For comparison mode
@@ -67,9 +70,9 @@ interface UseQueryStateReturn {
// parsed query
parsedQuery: Query | null;
- setExpressionParam: (value: string | undefined) => void;
- setSumByParam: (value: string | undefined) => void;
- setGroupByParam: (value: string[] | undefined) => void;
+ setExpressionParam: (value: string | null) => void;
+ setSumByParam: (value: string | null) => void;
+ setGroupByParam: (value: string[] | null) => void;
}
export const useQueryState = (options: UseQueryStateOptions = {}): UseQueryStateReturn => {
@@ -84,41 +87,65 @@ export const useQueryState = (options: UseQueryStateOptions = {}): UseQueryState
onProfileTypeChange,
} = options;
- const batchUpdates = useURLStateBatch();
const resetFlameGraphState = useResetFlameGraphState();
const resetStateOnProfileTypeChange = useResetStateOnProfileTypeChange();
- // URL state hooks with appropriate suffixes
- const [expression, setExpressionState] = useURLState(`expression${suffix}`, {
- defaultValue: defaultExpression,
- });
-
- const [from, setFromState] = useURLState(`from${suffix}`, {
- defaultValue: defaultFrom?.toString(),
- });
-
- const [to, setToState] = useURLState(`to${suffix}`, {
- defaultValue: defaultTo?.toString(),
- });
-
- const [timeSelection, setTimeSelectionState] = useURLState(`time_selection${suffix}`, {
- defaultValue: defaultTimeSelection,
- });
-
- const [sumByParam, setSumByParam] = useURLState(`sum_by${suffix}`);
-
- const [, setGroupByParam] = useURLState('group_by', {
- alwaysReturnArray: true,
- });
+ // URL state hooks with appropriate suffixes via useQueryStates
+ const [queryParams, setQueryParams] = useQueryStates(
+ {
+ expression: stringParam,
+ from: stringParam,
+ to: stringParam,
+ time_selection: stringParam,
+ sum_by: stringParam,
+ merge_from: stringParam,
+ merge_to: stringParam,
+ selection: stringParam,
+ },
+ {
+ history: 'replace',
+ urlKeys: {
+ expression: `expression${suffix}`,
+ from: `from${suffix}`,
+ to: `to${suffix}`,
+ time_selection: `time_selection${suffix}`,
+ sum_by: `sum_by${suffix}`,
+ merge_from: `merge_from${suffix}`,
+ merge_to: `merge_to${suffix}`,
+ selection: `selection${suffix}`,
+ },
+ }
+ );
- const [mergeFrom, setMergeFromState] = useURLState(`merge_from${suffix}`);
- const [mergeTo, setMergeToState] = useURLState(`merge_to${suffix}`);
+ const expression = queryParams.expression ?? defaultExpression;
+ const from = queryParams.from ?? defaultFrom?.toString();
+ const to = queryParams.to ?? defaultTo?.toString();
+ const timeSelection = queryParams.time_selection ?? defaultTimeSelection;
+ const sumByParam = queryParams.sum_by;
+ const mergeFrom = queryParams.merge_from;
+ const mergeTo = queryParams.merge_to;
+ const selectionParam = queryParams.selection;
+
+ // Individual setters for direct access
+ const setExpressionState = useCallback(
+ (val: string | null) => void setQueryParams({expression: val}),
+ [setQueryParams]
+ );
+ const setSumByParam = useCallback(
+ (val: string | null) => void setQueryParams({sum_by: val}),
+ [setQueryParams]
+ );
- // ProfileSelection URL state hooks - reuses merge_from/merge_to but adds selection
- const [selectionParam, setSelectionParam] = useURLState(`selection${suffix}`);
+ const [, setRawGroupByParam] = useNuqsQueryState('group_by', commaArrayParam);
+ const setGroupByParam = useCallback(
+ (val: string[] | null) => {
+ void setRawGroupByParam(val);
+ },
+ [setRawGroupByParam]
+ );
// Parse sumBy from URL parameter format
- const sumBy = useSumByFromParams(sumByParam);
+ const sumBy = useSumByFromParams(sumByParam ?? undefined);
// Draft state management
const [draftExpression, setDraftExpression] = useState(expression ?? defaultExpression);
@@ -203,8 +230,8 @@ export const useQueryState = (options: UseQueryStateOptions = {}): UseQueryState
// Sync computed sumBy to URL if URL doesn't already have a value
// to ensure the shared URL can always pick it up.
useEffect(() => {
- if (sumByParam === undefined && computedSumByFromURL !== undefined && !sumBySelectionLoading) {
- setSumByParam(sumByToParam(computedSumByFromURL));
+ if (sumByParam === null && computedSumByFromURL !== undefined && !sumBySelectionLoading) {
+ void setSumByParam(sumByToParam(computedSumByFromURL));
}
}, [sumByParam, computedSumByFromURL, sumBySelectionLoading, setSumByParam]);
@@ -212,8 +239,8 @@ export const useQueryState = (options: UseQueryStateOptions = {}): UseQueryState
const querySelection: QuerySelection = useMemo(() => {
const range = DateTimeRange.fromRangeKey(
timeSelection ?? defaultTimeSelection,
- from !== undefined && from !== '' ? parseInt(from) : defaultFrom,
- to !== undefined && to !== '' ? parseInt(to) : defaultTo
+ from != null && from !== '' ? parseInt(from) : defaultFrom,
+ to != null && to !== '' ? parseInt(to) : defaultTo
);
return {
@@ -222,7 +249,7 @@ export const useQueryState = (options: UseQueryStateOptions = {}): UseQueryState
to: range.getToMs(),
timeSelection: range.getRangeKey(),
sumBy: computedSumByFromURL,
- ...(mergeFrom !== undefined && mergeFrom !== '' && mergeTo !== undefined && mergeTo !== ''
+ ...(mergeFrom != null && mergeFrom !== '' && mergeTo != null && mergeTo !== ''
? {mergeFrom, mergeTo}
: {}),
};
@@ -275,7 +302,11 @@ export const useQueryState = (options: UseQueryStateOptions = {}): UseQueryState
// Compute ProfileSelection from URL params
const profileSelection = useMemo(() => {
- return ProfileSelectionFromParams(mergeFrom, mergeTo, selectionParam);
+ return ProfileSelectionFromParams(
+ mergeFrom ?? undefined,
+ mergeTo ?? undefined,
+ selectionParam ?? undefined
+ );
}, [mergeFrom, mergeTo, selectionParam]);
// Compute ProfileSource from ProfileSelection
@@ -293,83 +324,77 @@ export const useQueryState = (options: UseQueryStateOptions = {}): UseQueryState
refreshedTimeRange?: {from: number; to: number; timeSelection: string},
expression?: string
) => {
- batchUpdates(() => {
- // Use provided expression or current draft expression
- const finalExpression = expression ?? draftExpression;
+ // Use provided expression or current draft expression
+ const finalExpression = expression ?? draftExpression;
- // Update draft state with new expression if provided
- if (expression !== undefined) {
- setDraftExpression(expression);
- }
+ // Update draft state with new expression if provided
+ if (expression !== undefined) {
+ setDraftExpression(expression);
+ }
- // Calculate the actual from/to values from draftSelection if not provided
- const calculatedFrom = draftSelection.from.toString();
- const calculatedTo = draftSelection.to.toString();
+ // Calculate the actual from/to values from draftSelection if not provided
+ const calculatedFrom = draftSelection.from.toString();
+ const calculatedTo = draftSelection.to.toString();
- const finalFrom =
- refreshedTimeRange?.from?.toString() ?? (draftFrom !== '' ? draftFrom : calculatedFrom);
- const finalTo =
- refreshedTimeRange?.to?.toString() ?? (draftTo !== '' ? draftTo : calculatedTo);
- const finalTimeSelection = refreshedTimeRange?.timeSelection ?? draftTimeSelection;
+ const finalFrom =
+ refreshedTimeRange?.from?.toString() ?? (draftFrom !== '' ? draftFrom : calculatedFrom);
+ const finalTo =
+ refreshedTimeRange?.to?.toString() ?? (draftTo !== '' ? draftTo : calculatedTo);
+ const finalTimeSelection = refreshedTimeRange?.timeSelection ?? draftTimeSelection;
- // Update draft state with refreshed time range if provided
- if (refreshedTimeRange?.from !== undefined) {
- setDraftFrom(finalFrom);
- }
- if (refreshedTimeRange?.to !== undefined) {
- setDraftTo(finalTo);
- }
- if (refreshedTimeRange?.timeSelection !== undefined) {
- setDraftTimeSelection(finalTimeSelection);
- }
+ // Update draft state with refreshed time range if provided
+ if (refreshedTimeRange?.from !== undefined) {
+ setDraftFrom(finalFrom);
+ }
+ if (refreshedTimeRange?.to !== undefined) {
+ setDraftTo(finalTo);
+ }
+ if (refreshedTimeRange?.timeSelection !== undefined) {
+ setDraftTimeSelection(finalTimeSelection);
+ }
- setExpressionState(finalExpression);
- setFromState(finalFrom);
- setToState(finalTo);
- setTimeSelectionState(finalTimeSelection);
-
- // Auto-calculate merge parameters for delta profiles
- // Parse the final expression to check if it's a delta profile
- const finalQuery = Query.parse(finalExpression);
- const isDelta = finalQuery.profileType().delta;
- if (isDelta) {
- setSumByParam(sumByToParam(draftSumBy));
- } else {
- setSumByParam(DEFAULT_EMPTY_SUM_BY);
- }
+ // Auto-calculate merge parameters for delta profiles
+ const finalQuery = Query.parse(finalExpression);
+ const isDelta = finalQuery.profileType().delta;
- if (isDelta && finalFrom !== '' && finalTo !== '') {
- const fromMs = parseInt(finalFrom);
- const toMs = parseInt(finalTo);
- setMergeFromState((BigInt(fromMs) * 1_000_000n).toString());
- setMergeToState((BigInt(toMs) * 1_000_000n).toString());
-
- // Auto-select the time range for delta profiles (but not in compare mode)
- // This applies both on initial load AND when Search is clicked
- // The selection will use the final expression and the updated time range
- if (!comparing) {
- setSelectionParam(finalExpression);
- } else {
- setSelectionParam(undefined);
- }
- } else {
- setMergeFromState(undefined);
- setMergeToState(undefined);
- // Clear ProfileSelection for non-delta profiles
- setSelectionParam(undefined);
- }
- resetFlameGraphState();
- if (
- draftProfileType.toString() !==
- Query.parse(querySelection.expression).profileType().toString()
- ) {
- resetStateOnProfileTypeChange();
- onProfileTypeChange?.();
+ const sumByValue = isDelta ? sumByToParam(draftSumBy) : sumByToParam(DEFAULT_EMPTY_SUM_BY);
+ let mergeFromValue: string | null = null;
+ let mergeToValue: string | null = null;
+ let selectionValue: string | null = null;
+
+ if (isDelta && finalFrom !== '' && finalTo !== '') {
+ const fromMs = parseInt(finalFrom);
+ const toMs = parseInt(finalTo);
+ mergeFromValue = (BigInt(fromMs) * 1_000_000n).toString();
+ mergeToValue = (BigInt(toMs) * 1_000_000n).toString();
+
+ if (!comparing) {
+ selectionValue = finalExpression;
}
+ }
+
+ // Atomic URL update with all params at once
+ void setQueryParams({
+ expression: finalExpression,
+ from: finalFrom,
+ to: finalTo,
+ time_selection: finalTimeSelection,
+ sum_by: sumByValue,
+ merge_from: mergeFromValue,
+ merge_to: mergeToValue,
+ selection: selectionValue,
});
+
+ resetFlameGraphState();
+ if (
+ draftProfileType.toString() !==
+ Query.parse(querySelection.expression).profileType().toString()
+ ) {
+ resetStateOnProfileTypeChange();
+ onProfileTypeChange?.();
+ }
},
[
- batchUpdates,
draftExpression,
draftFrom,
draftTo,
@@ -378,14 +403,7 @@ export const useQueryState = (options: UseQueryStateOptions = {}): UseQueryState
draftSelection.from,
draftSelection.to,
comparing,
- setExpressionState,
- setFromState,
- setToState,
- setTimeSelectionState,
- setSumByParam,
- setMergeFromState,
- setMergeToState,
- setSelectionParam,
+ setQueryParams,
resetFlameGraphState,
resetStateOnProfileTypeChange,
onProfileTypeChange,
@@ -434,13 +452,13 @@ export const useQueryState = (options: UseQueryStateOptions = {}): UseQueryState
// Set ProfileSelection (auto-commits to URL immediately)
const setProfileSelection = useCallback(
(mergeFrom: bigint, mergeTo: bigint, query: Query) => {
- batchUpdates(() => {
- setSelectionParam(query.toString());
- setMergeFromState(mergeFrom.toString());
- setMergeToState(mergeTo.toString());
+ void setQueryParams({
+ selection: query.toString(),
+ merge_from: mergeFrom.toString(),
+ merge_to: mergeTo.toString(),
});
},
- [batchUpdates, setSelectionParam, setMergeFromState, setMergeToState]
+ [setQueryParams]
);
const draftParsedQuery = useMemo(() => {
diff --git a/ui/packages/shared/profile/src/index.tsx b/ui/packages/shared/profile/src/index.tsx
index c0a1ececa07..05ea6982348 100644
--- a/ui/packages/shared/profile/src/index.tsx
+++ b/ui/packages/shared/profile/src/index.tsx
@@ -14,8 +14,6 @@
import {CompressionType, setCompressionCodec} from '@uwdata/flechette';
import * as lz4 from 'lz4js';
-import type {ParamPreferences} from '@parca/components';
-
import MatchersInput from './MatchersInput';
import MetricsGraph, {type ContextMenuItemOrSubmenu, type Series} from './MetricsGraph';
import ProfileExplorer from './ProfileExplorer';
@@ -60,19 +58,6 @@ export {QueryControls} from './QueryControls';
export {default as ProfileFilters} from './ProfileView/components/ProfileFilters';
export {useProfileFiltersUrlState} from './ProfileView/components/ProfileFilters/useProfileFiltersUrlState';
-export const DEFAULT_PROFILE_EXPLORER_PARAM_VALUES: ParamPreferences = {
- dashboard_items: {
- defaultValue: 'flamegraph',
- splitOnCommas: true, // This param should split on commas for array values
- },
- group_by: {
- splitOnCommas: true,
- },
- flamechart_dimension: {
- splitOnCommas: true,
- },
-};
-
export {useProfileTypes} from './ProfileSelector';
export {
diff --git a/ui/packages/shared/profile/src/useSumBy.ts b/ui/packages/shared/profile/src/useSumBy.ts
index 935a4c18c95..19a2581a7d4 100644
--- a/ui/packages/shared/profile/src/useSumBy.ts
+++ b/ui/packages/shared/profile/src/useSumBy.ts
@@ -183,16 +183,16 @@ export const useSumByFromParams = (param: string | string[] | undefined): string
return sumBy;
};
-export const sumByToParam = (sumBy: string[] | undefined): string | string[] | undefined => {
+export const sumByToParam = (sumBy: string[] | undefined): string | null => {
if (sumBy === undefined) {
- return undefined;
+ return null;
}
if (sumBy.length === 0) {
return '__none__';
}
- return sumBy;
+ return sumBy.join(',');
};
// Combined hook that handles all sumBy logic: fetching labels, computing defaults, and managing selection
diff --git a/ui/pnpm-lock.yaml b/ui/pnpm-lock.yaml
index 96995685c82..8503d376c0f 100644
--- a/ui/pnpm-lock.yaml
+++ b/ui/pnpm-lock.yaml
@@ -372,6 +372,9 @@ importers:
moment:
specifier: 2.30.1
version: 2.30.1
+ nuqs:
+ specifier: ^2.4.1
+ version: 2.8.9(react-router-dom@6.30.3(react-dom@18.3.1(react@18.3.1))(react@18.3.1))(react-router@6.30.3(react@18.3.1))(react@18.3.1)
postcss:
specifier: 8.5.8
version: 8.5.8
@@ -551,6 +554,9 @@ importers:
moment-timezone:
specifier: ^0.6.0
version: 0.6.0
+ nuqs:
+ specifier: ^2.4.1
+ version: 2.8.9(react-router-dom@6.30.3(react-dom@18.3.1(react@18.3.1))(react@18.3.1))(react-router@6.30.3(react@18.3.1))(react@18.3.1)
react-datepicker:
specifier: 6.9.0
version: 6.9.0(react-dom@18.3.1(react@18.3.1))(react@18.3.1)
@@ -571,7 +577,7 @@ importers:
version: 1.14.0
tailwindcss:
specifier: 3.2.4
- version: 3.2.4(postcss@8.5.8)(ts-node@10.9.2(@swc/core@1.15.10(@swc/helpers@0.5.18))(@types/node@18.19.130)(typescript@5.9.3))
+ version: 3.2.4(postcss@8.5.8)(ts-node@10.9.2(@swc/core@1.15.10(@swc/helpers@0.5.18))(@types/node@24.10.9)(typescript@5.9.3))
tsc-watch:
specifier: 6.3.1
version: 6.3.1(typescript@5.9.3)
@@ -771,6 +777,9 @@ importers:
lz4js:
specifier: ^0.2.0
version: 0.2.0
+ nuqs:
+ specifier: ^2.4.1
+ version: 2.8.9(react-router-dom@6.30.3(react-dom@18.3.1(react@18.3.1))(react@18.3.1))(react-router@6.30.3(react@18.3.1))(react@18.3.1)
react:
specifier: 18.3.1
version: 18.3.1
@@ -818,7 +827,7 @@ importers:
version: 17.6.0(react-dom@18.3.1(react@18.3.1))(react@18.3.1)
tailwindcss:
specifier: 3.2.4
- version: 3.2.4(postcss@8.5.8)(ts-node@10.9.2(@swc/core@1.15.10(@swc/helpers@0.5.18))(@types/node@18.19.130)(typescript@5.9.3))
+ version: 3.2.4(postcss@8.5.8)(ts-node@10.9.2(@swc/core@1.15.10(@swc/helpers@0.5.18))(@types/node@24.10.9)(typescript@5.9.3))
tsc-watch:
specifier: 6.3.1
version: 6.3.1(typescript@5.9.3)
@@ -892,7 +901,7 @@ importers:
version: 3.2.0(date-fns@3.6.0)
tailwindcss:
specifier: 3.2.4
- version: 3.2.4(postcss@8.5.8)(ts-node@10.9.2(@swc/core@1.15.10(@swc/helpers@0.5.18))(@types/node@18.19.130)(typescript@5.9.3))
+ version: 3.2.4(postcss@8.5.8)(ts-node@10.9.2(@swc/core@1.15.10(@swc/helpers@0.5.18))(@types/node@24.10.9)(typescript@5.9.3))
tsc-watch:
specifier: 6.3.1
version: 6.3.1(typescript@5.9.3)
@@ -2821,49 +2830,42 @@ packages:
engines: {node: '>= 10'}
cpu: [arm64]
os: [linux]
- libc: [glibc]
'@napi-rs/nice-linux-arm64-musl@1.1.1':
resolution: {integrity: sha512-+2Rzdb3nTIYZ0YJF43qf2twhqOCkiSrHx2Pg6DJaCPYhhaxbLcdlV8hCRMHghQ+EtZQWGNcS2xF4KxBhSGeutg==}
engines: {node: '>= 10'}
cpu: [arm64]
os: [linux]
- libc: [musl]
'@napi-rs/nice-linux-ppc64-gnu@1.1.1':
resolution: {integrity: sha512-4FS8oc0GeHpwvv4tKciKkw3Y4jKsL7FRhaOeiPei0X9T4Jd619wHNe4xCLmN2EMgZoeGg+Q7GY7BsvwKpL22Tg==}
engines: {node: '>= 10'}
cpu: [ppc64]
os: [linux]
- libc: [glibc]
'@napi-rs/nice-linux-riscv64-gnu@1.1.1':
resolution: {integrity: sha512-HU0nw9uD4FO/oGCCk409tCi5IzIZpH2agE6nN4fqpwVlCn5BOq0MS1dXGjXaG17JaAvrlpV5ZeyZwSon10XOXw==}
engines: {node: '>= 10'}
cpu: [riscv64]
os: [linux]
- libc: [glibc]
'@napi-rs/nice-linux-s390x-gnu@1.1.1':
resolution: {integrity: sha512-2YqKJWWl24EwrX0DzCQgPLKQBxYDdBxOHot1KWEq7aY2uYeX+Uvtv4I8xFVVygJDgf6/92h9N3Y43WPx8+PAgQ==}
engines: {node: '>= 10'}
cpu: [s390x]
os: [linux]
- libc: [glibc]
'@napi-rs/nice-linux-x64-gnu@1.1.1':
resolution: {integrity: sha512-/gaNz3R92t+dcrfCw/96pDopcmec7oCcAQ3l/M+Zxr82KT4DljD37CpgrnXV+pJC263JkW572pdbP3hP+KjcIg==}
engines: {node: '>= 10'}
cpu: [x64]
os: [linux]
- libc: [glibc]
'@napi-rs/nice-linux-x64-musl@1.1.1':
resolution: {integrity: sha512-xScCGnyj/oppsNPMnevsBe3pvNaoK7FGvMjT35riz9YdhB2WtTG47ZlbxtOLpjeO9SqqQ2J2igCmz6IJOD5JYw==}
engines: {node: '>= 10'}
cpu: [x64]
os: [linux]
- libc: [musl]
'@napi-rs/nice-openharmony-arm64@1.1.1':
resolution: {integrity: sha512-6uJPRVwVCLDeoOaNyeiW0gp2kFIM4r7PL2MczdZQHkFi9gVlgm+Vn+V6nTWRcu856mJ2WjYJiumEajfSm7arPQ==}
@@ -3039,25 +3041,21 @@ packages:
resolution: {integrity: sha512-NmPeCexWIZHW9RM3lDdFENN9C3WtlQ5L4RSNFESIjreS921rgePhulsszYdGnHdcnKPYlBBJnX/NxVsfioBbnQ==}
cpu: [arm64]
os: [linux]
- libc: [glibc]
'@nx/nx-linux-arm64-musl@22.3.3':
resolution: {integrity: sha512-K02U88Q0dpvCfmSXXvY7KbYQSa1m+mkYeqDBRHp11yHk1GoIqaHp8oEWda7FV4gsriNExPSS5tX1/QGVoLZrCw==}
cpu: [arm64]
os: [linux]
- libc: [musl]
'@nx/nx-linux-x64-gnu@22.3.3':
resolution: {integrity: sha512-04TEbvgwRaB9ifr39YwJmWh3RuXb4Ry4m84SOJyjNXAfPrepcWgfIQn1VL2ul1Ybq+P023dLO9ME8uqFh6j1YQ==}
cpu: [x64]
os: [linux]
- libc: [glibc]
'@nx/nx-linux-x64-musl@22.3.3':
resolution: {integrity: sha512-uxBXx5q+S5OGatbYDxnamsKXRKlYn+Eq1nrCAHaf8rIfRoHlDiRV2PqtWuF+O2pxR5FWKpvr+/sZtt9rAf7KMw==}
cpu: [x64]
os: [linux]
- libc: [musl]
'@nx/nx-win32-arm64-msvc@22.3.3':
resolution: {integrity: sha512-aOwlfD6ZA1K6hjZtbhBSp7s1yi3sHbMpLCa4stXzfhCCpKUv46HU/EdiWdE1N8AsyNFemPZFq81k1VTowcACdg==}
@@ -3162,42 +3160,36 @@ packages:
engines: {node: '>= 10.0.0'}
cpu: [arm]
os: [linux]
- libc: [glibc]
'@parcel/watcher-linux-arm-musl@2.5.4':
resolution: {integrity: sha512-kGO8RPvVrcAotV4QcWh8kZuHr9bXi9a3bSZw7kFarYR0+fGliU7hd/zevhjw8fnvIKG3J9EO5G6sXNGCSNMYPQ==}
engines: {node: '>= 10.0.0'}
cpu: [arm]
os: [linux]
- libc: [musl]
'@parcel/watcher-linux-arm64-glibc@2.5.4':
resolution: {integrity: sha512-KU75aooXhqGFY2W5/p8DYYHt4hrjHZod8AhcGAmhzPn/etTa+lYCDB2b1sJy3sWJ8ahFVTdy+EbqSBvMx3iFlw==}
engines: {node: '>= 10.0.0'}
cpu: [arm64]
os: [linux]
- libc: [glibc]
'@parcel/watcher-linux-arm64-musl@2.5.4':
resolution: {integrity: sha512-Qx8uNiIekVutnzbVdrgSanM+cbpDD3boB1f8vMtnuG5Zau4/bdDbXyKwIn0ToqFhIuob73bcxV9NwRm04/hzHQ==}
engines: {node: '>= 10.0.0'}
cpu: [arm64]
os: [linux]
- libc: [musl]
'@parcel/watcher-linux-x64-glibc@2.5.4':
resolution: {integrity: sha512-UYBQvhYmgAv61LNUn24qGQdjtycFBKSK3EXr72DbJqX9aaLbtCOO8+1SkKhD/GNiJ97ExgcHBrukcYhVjrnogA==}
engines: {node: '>= 10.0.0'}
cpu: [x64]
os: [linux]
- libc: [glibc]
'@parcel/watcher-linux-x64-musl@2.5.4':
resolution: {integrity: sha512-YoRWCVgxv8akZrMhdyVi6/TyoeeMkQ0PGGOf2E4omODrvd1wxniXP+DBynKoHryStks7l+fDAMUBRzqNHrVOpg==}
engines: {node: '>= 10.0.0'}
cpu: [x64]
os: [linux]
- libc: [musl]
'@parcel/watcher-win32-arm64@2.5.4':
resolution: {integrity: sha512-iby+D/YNXWkiQNYcIhg8P5hSjzXEHaQrk2SLrWOUD7VeC4Ohu0WQvmV+HDJokZVJ2UjJ4AGXW3bx7Lls9Ln4TQ==}
@@ -3384,79 +3376,66 @@ packages:
resolution: {integrity: sha512-Rn3n+FUk2J5VWx+ywrG/HGPTD9jXNbicRtTM11e/uorplArnXZYsVifnPPqNNP5BsO3roI4n8332ukpY/zN7rQ==}
cpu: [arm]
os: [linux]
- libc: [glibc]
'@rollup/rollup-linux-arm-musleabihf@4.55.1':
resolution: {integrity: sha512-grPNWydeKtc1aEdrJDWk4opD7nFtQbMmV7769hiAaYyUKCT1faPRm2av8CX1YJsZ4TLAZcg9gTR1KvEzoLjXkg==}
cpu: [arm]
os: [linux]
- libc: [musl]
'@rollup/rollup-linux-arm64-gnu@4.55.1':
resolution: {integrity: sha512-a59mwd1k6x8tXKcUxSyISiquLwB5pX+fJW9TkWU46lCqD/GRDe9uDN31jrMmVP3feI3mhAdvcCClhV8V5MhJFQ==}
cpu: [arm64]
os: [linux]
- libc: [glibc]
'@rollup/rollup-linux-arm64-musl@4.55.1':
resolution: {integrity: sha512-puS1MEgWX5GsHSoiAsF0TYrpomdvkaXm0CofIMG5uVkP6IBV+ZO9xhC5YEN49nsgYo1DuuMquF9+7EDBVYu4uA==}
cpu: [arm64]
os: [linux]
- libc: [musl]
'@rollup/rollup-linux-loong64-gnu@4.55.1':
resolution: {integrity: sha512-r3Wv40in+lTsULSb6nnoudVbARdOwb2u5fpeoOAZjFLznp6tDU8kd+GTHmJoqZ9lt6/Sys33KdIHUaQihFcu7g==}
cpu: [loong64]
os: [linux]
- libc: [glibc]
'@rollup/rollup-linux-loong64-musl@4.55.1':
resolution: {integrity: sha512-MR8c0+UxAlB22Fq4R+aQSPBayvYa3+9DrwG/i1TKQXFYEaoW3B5b/rkSRIypcZDdWjWnpcvxbNaAJDcSbJU3Lw==}
cpu: [loong64]
os: [linux]
- libc: [musl]
'@rollup/rollup-linux-ppc64-gnu@4.55.1':
resolution: {integrity: sha512-3KhoECe1BRlSYpMTeVrD4sh2Pw2xgt4jzNSZIIPLFEsnQn9gAnZagW9+VqDqAHgm1Xc77LzJOo2LdigS5qZ+gw==}
cpu: [ppc64]
os: [linux]
- libc: [glibc]
'@rollup/rollup-linux-ppc64-musl@4.55.1':
resolution: {integrity: sha512-ziR1OuZx0vdYZZ30vueNZTg73alF59DicYrPViG0NEgDVN8/Jl87zkAPu4u6VjZST2llgEUjaiNl9JM6HH1Vdw==}
cpu: [ppc64]
os: [linux]
- libc: [musl]
'@rollup/rollup-linux-riscv64-gnu@4.55.1':
resolution: {integrity: sha512-uW0Y12ih2XJRERZ4jAfKamTyIHVMPQnTZcQjme2HMVDAHY4amf5u414OqNYC+x+LzRdRcnIG1YodLrrtA8xsxw==}
cpu: [riscv64]
os: [linux]
- libc: [glibc]
'@rollup/rollup-linux-riscv64-musl@4.55.1':
resolution: {integrity: sha512-u9yZ0jUkOED1BFrqu3BwMQoixvGHGZ+JhJNkNKY/hyoEgOwlqKb62qu+7UjbPSHYjiVy8kKJHvXKv5coH4wDeg==}
cpu: [riscv64]
os: [linux]
- libc: [musl]
'@rollup/rollup-linux-s390x-gnu@4.55.1':
resolution: {integrity: sha512-/0PenBCmqM4ZUd0190j7J0UsQ/1nsi735iPRakO8iPciE7BQ495Y6msPzaOmvx0/pn+eJVVlZrNrSh4WSYLxNg==}
cpu: [s390x]
os: [linux]
- libc: [glibc]
'@rollup/rollup-linux-x64-gnu@4.55.1':
resolution: {integrity: sha512-a8G4wiQxQG2BAvo+gU6XrReRRqj+pLS2NGXKm8io19goR+K8lw269eTrPkSdDTALwMmJp4th2Uh0D8J9bEV1vg==}
cpu: [x64]
os: [linux]
- libc: [glibc]
'@rollup/rollup-linux-x64-musl@4.55.1':
resolution: {integrity: sha512-bD+zjpFrMpP/hqkfEcnjXWHMw5BIghGisOKPj+2NaNDuVT+8Ds4mPf3XcPHuat1tz89WRL+1wbcxKY3WSbiT7w==}
cpu: [x64]
os: [linux]
- libc: [musl]
'@rollup/rollup-openbsd-x64@4.55.1':
resolution: {integrity: sha512-eLXw0dOiqE4QmvikfQ6yjgkg/xDM+MdU9YJuP4ySTibXU0oAvnEWXt7UDJmD4UkYialMfOGFPJnIHSe/kdzPxg==}
@@ -3543,6 +3522,9 @@ packages:
'@sinonjs/fake-timers@8.1.0':
resolution: {integrity: sha512-OAPJUAtgeINhh/TAlUID4QTs53Njm7xzddaVlEs/SXwgtiD1tW22zAB/W1wdqfrpmikgaWQ9Fw6Ws+hsiRm5Vg==}
+ '@standard-schema/spec@1.0.0':
+ resolution: {integrity: sha512-m2bOd0f2RT9k8QJx1JN85cZYyH1RqFBdlwtkSlf4tBDYLCiiZnv1fIIwacK6cqwXavOydf0NPToMQgpKq+dVlA==}
+
'@storybook/addon-actions@8.6.14':
resolution: {integrity: sha512-mDQxylxGGCQSK7tJPkD144J8jWh9IU9ziJMHfB84PKpI/V5ZgqMDnpr2bssTrUaGDqU5e1/z8KcRF+Melhs9pQ==}
peerDependencies:
@@ -4032,28 +4014,24 @@ packages:
engines: {node: '>=10'}
cpu: [arm64]
os: [linux]
- libc: [glibc]
'@swc/core-linux-arm64-musl@1.15.10':
resolution: {integrity: sha512-4uAHO3nbfbrTcmO/9YcVweTQdx5fN3l7ewwl5AEK4yoC4wXmoBTEPHAVdKNe4r9+xrTgd4BgyPsy0409OjjlMw==}
engines: {node: '>=10'}
cpu: [arm64]
os: [linux]
- libc: [musl]
'@swc/core-linux-x64-gnu@1.15.10':
resolution: {integrity: sha512-W0h9ONNw1pVIA0cN7wtboOSTl4Jk3tHq+w2cMPQudu9/+3xoCxpFb9ZdehwCAk29IsvdWzGzY6P7dDVTyFwoqg==}
engines: {node: '>=10'}
cpu: [x64]
os: [linux]
- libc: [glibc]
'@swc/core-linux-x64-musl@1.15.10':
resolution: {integrity: sha512-XQNZlLZB62S8nAbw7pqoqwy91Ldy2RpaMRqdRN3T+tAg6Xg6FywXRKCsLh6IQOadr4p1+lGnqM/Wn35z5a/0Vw==}
engines: {node: '>=10'}
cpu: [x64]
os: [linux]
- libc: [musl]
'@swc/core-win32-arm64-msvc@1.15.10':
resolution: {integrity: sha512-qnAGrRv5Nj/DATxAmCnJQRXXQqnJwR0trxLndhoHoxGci9MuguNIjWahS0gw8YZFjgTinbTxOwzatkoySihnmw==}
@@ -10156,6 +10134,27 @@ packages:
nth-check@2.1.1:
resolution: {integrity: sha512-lqjrjmaOoAnWfMmBPL+XNnynZh2+swxiX3WUE0s4yEHI6m+AwrK2UZOimIRl3X/4QctVqS8AiZjFqyOGrMXb/w==}
+ nuqs@2.8.9:
+ resolution: {integrity: sha512-8ou6AEwsxMWSYo2qkfZtYFVzngwbKmg4c00HVxC1fF6CEJv3Fwm6eoZmfVPALB+vw8Udo7KL5uy96PFcYe1BIQ==}
+ peerDependencies:
+ '@remix-run/react': '>=2'
+ '@tanstack/react-router': ^1
+ next: '>=14.2.0'
+ react: '>=18.2.0 || ^19.0.0-0'
+ react-router: ^5 || ^6 || ^7
+ react-router-dom: ^5 || ^6 || ^7
+ peerDependenciesMeta:
+ '@remix-run/react':
+ optional: true
+ '@tanstack/react-router':
+ optional: true
+ next:
+ optional: true
+ react-router:
+ optional: true
+ react-router-dom:
+ optional: true
+
nwsapi@2.2.23:
resolution: {integrity: sha512-7wfH4sLbt4M0gCDzGE6vzQBo0bfTKjU7Sfpqy/7gs1qBfYz2vEJH6vXcBKpO3+6Yu1telwd0t9HpyOoLEQQbIQ==}
@@ -17061,6 +17060,8 @@ snapshots:
dependencies:
'@sinonjs/commons': 1.8.6
+ '@standard-schema/spec@1.0.0': {}
+
'@storybook/addon-actions@8.6.14(storybook@8.6.17(prettier@3.6.2))':
dependencies:
'@storybook/global': 5.0.0
@@ -25597,6 +25598,14 @@ snapshots:
dependencies:
boolbase: 1.0.0
+ nuqs@2.8.9(react-router-dom@6.30.3(react-dom@18.3.1(react@18.3.1))(react@18.3.1))(react-router@6.30.3(react@18.3.1))(react@18.3.1):
+ dependencies:
+ '@standard-schema/spec': 1.0.0
+ react: 18.3.1
+ optionalDependencies:
+ react-router: 6.30.3(react@18.3.1)
+ react-router-dom: 6.30.3(react-dom@18.3.1(react@18.3.1))(react@18.3.1)
+
nwsapi@2.2.23: {}
nx@22.3.3(@swc/core@1.15.10(@swc/helpers@0.5.18)):
From 848ce87c988d71c20d0438253598d5ec3832a1c0 Mon Sep 17 00:00:00 2001
From: "pre-commit-ci-lite[bot]"
<117423508+pre-commit-ci-lite[bot]@users.noreply.github.com>
Date: Tue, 7 Apr 2026 10:31:27 +0000
Subject: [PATCH 2/7] [pre-commit.ci lite] apply automatic fixes
---
gen/proto/go/google/api/http.pb.go | 2 +-
proto/buf.lock | 4 ++--
ui/packages/shared/client/src/google/api/http.ts | 2 +-
3 files changed, 4 insertions(+), 4 deletions(-)
diff --git a/gen/proto/go/google/api/http.pb.go b/gen/proto/go/google/api/http.pb.go
index 14f7b4eff11..32a3f2caa58 100644
--- a/gen/proto/go/google/api/http.pb.go
+++ b/gen/proto/go/google/api/http.pb.go
@@ -1,4 +1,4 @@
-// Copyright 2025 Google LLC
+// Copyright 2026 Google LLC
//
// Licensed under the Apache License, Version 2.0 (the "License");
// you may not use this file except in compliance with the License.
diff --git a/proto/buf.lock b/proto/buf.lock
index 9ae79671931..04b8054b1ee 100644
--- a/proto/buf.lock
+++ b/proto/buf.lock
@@ -4,8 +4,8 @@ deps:
- remote: buf.build
owner: googleapis
repository: googleapis
- commit: 004180b77378443887d3b55cabc00384
- digest: shake256:d26c7c2fd95f0873761af33ca4a0c0d92c8577122b6feb74eb3b0a57ebe47a98ab24a209a0e91945ac4c77204e9da0c2de0020b2cedc27bdbcdea6c431eec69b
+ commit: 536964a08a534d51b8f30f2d6751f1f9
+ digest: shake256:b6d518a50df43704333587967830344b49247ac8cf0953847d710f2d72246f677aeba56593dcd78f9199afff8ae9498f8dd5efe54107e5a09c60fff872456ca9
- remote: buf.build
owner: grpc-ecosystem
repository: grpc-gateway
diff --git a/ui/packages/shared/client/src/google/api/http.ts b/ui/packages/shared/client/src/google/api/http.ts
index 8c211611541..4cde4df7f0e 100644
--- a/ui/packages/shared/client/src/google/api/http.ts
+++ b/ui/packages/shared/client/src/google/api/http.ts
@@ -2,7 +2,7 @@
// @generated from protobuf file "google/api/http.proto" (package "google.api", syntax proto3)
// tslint:disable
//
-// Copyright 2025 Google LLC
+// Copyright 2026 Google LLC
//
// Licensed under the Apache License, Version 2.0 (the "License");
// you may not use this file except in compliance with the License.
From c51c4e0e9777f5e1237333742a684358a3771f9f Mon Sep 17 00:00:00 2001
From: Manoj Vivek
Date: Wed, 8 Apr 2026 09:00:56 +0530
Subject: [PATCH 3/7] Color-by param handling fixed
---
.../profile/src/ProfileSelector/index.tsx | 8 +++-
.../components/ColorStackLegend.tsx | 5 +--
.../useProfileFiltersUrlState.test.tsx | 2 +-
.../hooks/useVisualizationState.ts | 15 +------
.../shared/profile/src/Table/index.tsx | 5 ++-
.../shared/profile/src/hooks/urlParsers.ts | 6 +--
.../shared/profile/src/hooks/useColorBy.ts | 44 +++++++++++++++++++
.../profile/src/hooks/useQueryState.test.tsx | 6 +--
8 files changed, 65 insertions(+), 26 deletions(-)
create mode 100644 ui/packages/shared/profile/src/hooks/useColorBy.ts
diff --git a/ui/packages/shared/profile/src/ProfileSelector/index.tsx b/ui/packages/shared/profile/src/ProfileSelector/index.tsx
index 276ef2fcdb2..8bea67ced63 100644
--- a/ui/packages/shared/profile/src/ProfileSelector/index.tsx
+++ b/ui/packages/shared/profile/src/ProfileSelector/index.tsx
@@ -113,10 +113,16 @@ const ProfileSelector = ({
onSearchHook,
}: ProfileSelectorProps): JSX.Element => {
const {externalProfilerComponent, additionalMetricsGraph} = useParcaContext();
- const [queryBrowserMode, setQueryBrowserMode] = useNuqsQueryState(
+ const [queryBrowserMode, setRawQueryBrowserMode] = useNuqsQueryState(
'query_browser_mode',
stringParam
);
+ const setQueryBrowserMode = useCallback(
+ (mode: string | null) => {
+ void setRawQueryBrowserMode(mode);
+ },
+ [setRawQueryBrowserMode]
+ );
const profileFilterDefaults = externalProfilerComponent?.profileFilterDefaults as
| ProfileFilter[]
diff --git a/ui/packages/shared/profile/src/ProfileView/components/ColorStackLegend.tsx b/ui/packages/shared/profile/src/ProfileView/components/ColorStackLegend.tsx
index 64e6f28f24b..35f429e81ce 100644
--- a/ui/packages/shared/profile/src/ProfileView/components/ColorStackLegend.tsx
+++ b/ui/packages/shared/profile/src/ProfileView/components/ColorStackLegend.tsx
@@ -15,14 +15,13 @@ import React, {useMemo} from 'react';
import {Icon} from '@iconify/react';
import cx from 'classnames';
-import {useQueryState} from 'nuqs';
import {USER_PREFERENCES, useCurrentColorProfile, useUserPreference} from '@parca/hooks';
import {EVERYTHING_ELSE, selectDarkMode, useAppSelector} from '@parca/store';
import {getMappingColors} from '../../ProfileFlameGraph/FlameGraphArrow';
import useMappingList from '../../ProfileFlameGraph/FlameGraphArrow/useMappingList';
-import {colorByParser} from '../../hooks/urlParsers';
+import {useColorBy} from '../../hooks/useColorBy';
import {useProfileFilters} from './ProfileFilters/useProfileFilters';
interface Props {
@@ -38,7 +37,7 @@ const ColorStackLegend = ({mappings, compareMode = false, loading}: Props): Reac
USER_PREFERENCES.FLAMEGRAPH_COLOR_PROFILE.key
);
- const [colorBy] = useQueryState('color_by', colorByParser);
+ const {colorBy} = useColorBy();
const {appliedFilters, removeExcludeBinary, excludeBinary} = useProfileFilters();
diff --git a/ui/packages/shared/profile/src/ProfileView/components/ProfileFilters/useProfileFiltersUrlState.test.tsx b/ui/packages/shared/profile/src/ProfileView/components/ProfileFilters/useProfileFiltersUrlState.test.tsx
index f991534ce7b..c0179b3a15d 100644
--- a/ui/packages/shared/profile/src/ProfileView/components/ProfileFilters/useProfileFiltersUrlState.test.tsx
+++ b/ui/packages/shared/profile/src/ProfileView/components/ProfileFilters/useProfileFiltersUrlState.test.tsx
@@ -16,7 +16,7 @@ import {type ReactNode} from 'react';
// eslint-disable-next-line import/named
import {act, renderHook, waitFor} from '@testing-library/react';
import {NuqsTestingAdapter, type OnUrlUpdateFunction} from 'nuqs/adapters/testing';
-import {beforeEach, describe, expect, it, vi} from 'vitest';
+import {describe, expect, it, vi} from 'vitest';
import {type ProfileFilter} from './useProfileFilters';
import {decodeProfileFilters, useProfileFiltersUrlState} from './useProfileFiltersUrlState';
diff --git a/ui/packages/shared/profile/src/ProfileView/hooks/useVisualizationState.ts b/ui/packages/shared/profile/src/ProfileView/hooks/useVisualizationState.ts
index 1a23683f46b..b54c1523ae6 100644
--- a/ui/packages/shared/profile/src/ProfileView/hooks/useVisualizationState.ts
+++ b/ui/packages/shared/profile/src/ProfileView/hooks/useVisualizationState.ts
@@ -26,12 +26,12 @@ import {
} from '../../ProfileFlameGraph/FlameGraphArrow';
import {CurrentPathFrame} from '../../ProfileFlameGraph/FlameGraphArrow/utils';
import {
- colorByParser,
flamechartDimensionParser,
groupByParser,
jsonParser,
stringParam,
} from '../../hooks/urlParsers';
+import {useColorBy} from '../../hooks/useColorBy';
import {useResetFlameGraphState} from './useResetFlameGraphState';
export const useVisualizationState = (): {
@@ -52,9 +52,6 @@ export const useVisualizationState = (): {
alignFunctionName: string;
setAlignFunctionName: (align: string) => void;
} => {
- const [colorByPreference, setColorByPreference] = useUserPreference(
- USER_PREFERENCES.COLOR_BY.key
- );
const [alignFunctionNamePreference, setAlignFunctionNamePreference] = useUserPreference(
USER_PREFERENCES.ALIGN_FUNCTION_NAME.key
);
@@ -70,7 +67,7 @@ export const useVisualizationState = (): {
[setRawCurPathArrow]
);
const [colorStackLegend] = useQueryState('color_stack_legend', stringParam);
- const [colorBy, setStoreColorBy] = useQueryState('color_by', colorByParser);
+ const {colorBy, setColorBy} = useColorBy();
const [alignFunctionNameRaw, setStoreAlignFunctionName] = useQueryState(
'align_function_name',
stringParam
@@ -145,14 +142,6 @@ export const useVisualizationState = (): {
setSandwichFunctionName(null);
}, [setSandwichFunctionName]);
- const setColorBy = useCallback(
- (value: string): void => {
- void setStoreColorBy(value);
- setColorByPreference(value);
- },
- [setStoreColorBy, setColorByPreference]
- );
-
const setAlignFunctionName = useCallback(
(value: string): void => {
void setStoreAlignFunctionName(value);
diff --git a/ui/packages/shared/profile/src/Table/index.tsx b/ui/packages/shared/profile/src/Table/index.tsx
index edbde37f6a3..15c60ef22b4 100644
--- a/ui/packages/shared/profile/src/Table/index.tsx
+++ b/ui/packages/shared/profile/src/Table/index.tsx
@@ -27,7 +27,8 @@ import useMappingList, {
useFilenamesList,
} from '../ProfileFlameGraph/FlameGraphArrow/useMappingList';
import {useProfileViewContext} from '../ProfileView/context/ProfileViewContext';
-import {colorByParser, dashboardItemsParser, stringParam} from '../hooks/urlParsers';
+import {dashboardItemsParser, stringParam} from '../hooks/urlParsers';
+import {useColorBy} from '../hooks/useColorBy';
import {alignedUint8Array} from '../utils';
import TableContextMenuWrapper, {TableContextMenuWrapperRef} from './TableContextMenuWrapper';
import {useColorManagement} from './hooks/useColorManagement';
@@ -75,7 +76,7 @@ export const Table = React.memo(function Table({
const currentColorProfile = useCurrentColorProfile();
const [dashboardItems] = useQueryState('dashboard_items', dashboardItemsParser);
const [_, setSandwichFunctionName] = useQueryState('sandwich_function_name', stringParam);
- const [colorBy, setColorBy] = useQueryState('color_by', colorByParser);
+ const {colorBy, setColorBy} = useColorBy();
const {isDarkMode} = useParcaContext();
const {compareMode} = useProfileViewContext();
diff --git a/ui/packages/shared/profile/src/hooks/urlParsers.ts b/ui/packages/shared/profile/src/hooks/urlParsers.ts
index 82edc2eae1a..81e84064ea5 100644
--- a/ui/packages/shared/profile/src/hooks/urlParsers.ts
+++ b/ui/packages/shared/profile/src/hooks/urlParsers.ts
@@ -22,7 +22,6 @@ export const intParam = parseAsInteger.withOptions(opts);
export const commaArrayParam = parseAsArrayOf(parseAsString, ',').withOptions(opts);
// === Param-specific parsers with defaults ===
-export const colorByParser = stringParam.withDefault('binary');
export const invertCallStackParser = boolParam.withDefault(false);
export const dashboardItemsParser = commaArrayParam.withDefault(['flamegraph']);
export const groupByParser = commaArrayParam;
@@ -31,9 +30,10 @@ export const tableColumnsParser = commaArrayParam;
export const hiddenBinariesParser = commaArrayParam.withDefault([]);
// === JSON parser with BigInt support ===
-export const jsonParser = () =>
- createParser({
+export function jsonParser(): ReturnType> {
+ return createParser({
parse: (value: string) => JSON.parse(value) as T,
serialize: (value: T) =>
JSON.stringify(value, (_, v) => (typeof v === 'bigint' ? v.toString() : v)),
}).withOptions(opts);
+}
diff --git a/ui/packages/shared/profile/src/hooks/useColorBy.ts b/ui/packages/shared/profile/src/hooks/useColorBy.ts
new file mode 100644
index 00000000000..f4aeee94483
--- /dev/null
+++ b/ui/packages/shared/profile/src/hooks/useColorBy.ts
@@ -0,0 +1,44 @@
+// Copyright 2026 The Parca Authors
+// TODO: This license is not consistent with the license used in the project.
+// Delete the inconsistent license and above line and rerun pre-commit to insert a good license.
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+import {useCallback} from 'react';
+
+import {useQueryState} from 'nuqs';
+
+import {USER_PREFERENCES, useUserPreference} from '@parca/hooks';
+
+import {stringParam} from './urlParsers';
+
+export const useColorBy = (): {
+ colorBy: string;
+ setColorBy: (value: string) => void;
+} => {
+ const [colorByPreference, setColorByPreference] = useUserPreference(
+ USER_PREFERENCES.COLOR_BY.key
+ );
+ const [colorByRaw, setRawColorBy] = useQueryState('color_by', stringParam);
+
+ const colorBy = colorByRaw ?? colorByPreference ?? 'binary';
+
+ const setColorBy = useCallback(
+ (value: string) => {
+ void setRawColorBy(value);
+ setColorByPreference(value);
+ },
+ [setRawColorBy, setColorByPreference]
+ );
+
+ return {colorBy, setColorBy};
+};
diff --git a/ui/packages/shared/profile/src/hooks/useQueryState.test.tsx b/ui/packages/shared/profile/src/hooks/useQueryState.test.tsx
index 85ad85cd9dd..ada373144f3 100644
--- a/ui/packages/shared/profile/src/hooks/useQueryState.test.tsx
+++ b/ui/packages/shared/profile/src/hooks/useQueryState.test.tsx
@@ -1051,9 +1051,9 @@ describe('useQueryState', () => {
// Get the committed state values to build reload URL
const historyParams = result1.current.profileSelection?.HistoryParams();
- const selectionA = historyParams?.selection ?? '';
- const mergeFromA = historyParams?.merge_from ?? '';
- const mergeToA = historyParams?.merge_to ?? '';
+ const selectionA = String(historyParams?.selection ?? '');
+ const mergeFromA = String(historyParams?.merge_from ?? '');
+ const mergeToA = String(historyParams?.merge_to ?? '');
unmount();
mockNavigateTo.mockClear();
From 8e779fe462ddc1186c7a2f627af61ec2b0c6476e Mon Sep 17 00:00:00 2001
From: Manoj Vivek
Date: Wed, 8 Apr 2026 09:49:14 +0530
Subject: [PATCH 4/7] Eslint fixes
---
.../useProfileFiltersUrlState.test.tsx | 2 +-
.../shared/profile/src/hooks/useColorBy.ts | 4 +---
.../profile/src/hooks/useQueryState.test.tsx | 15 ++++++++-------
3 files changed, 10 insertions(+), 11 deletions(-)
diff --git a/ui/packages/shared/profile/src/ProfileView/components/ProfileFilters/useProfileFiltersUrlState.test.tsx b/ui/packages/shared/profile/src/ProfileView/components/ProfileFilters/useProfileFiltersUrlState.test.tsx
index c0179b3a15d..65598d1858c 100644
--- a/ui/packages/shared/profile/src/ProfileView/components/ProfileFilters/useProfileFiltersUrlState.test.tsx
+++ b/ui/packages/shared/profile/src/ProfileView/components/ProfileFilters/useProfileFiltersUrlState.test.tsx
@@ -15,7 +15,7 @@ import {type ReactNode} from 'react';
// eslint-disable-next-line import/named
import {act, renderHook, waitFor} from '@testing-library/react';
-import {NuqsTestingAdapter, type OnUrlUpdateFunction} from 'nuqs/adapters/testing';
+import {NuqsTestingAdapter, type OnUrlUpdateFunction} from 'nuqs/dist/adapters/testing';
import {describe, expect, it, vi} from 'vitest';
import {type ProfileFilter} from './useProfileFilters';
diff --git a/ui/packages/shared/profile/src/hooks/useColorBy.ts b/ui/packages/shared/profile/src/hooks/useColorBy.ts
index f4aeee94483..3d9ce2b480f 100644
--- a/ui/packages/shared/profile/src/hooks/useColorBy.ts
+++ b/ui/packages/shared/profile/src/hooks/useColorBy.ts
@@ -1,6 +1,4 @@
-// Copyright 2026 The Parca Authors
-// TODO: This license is not consistent with the license used in the project.
-// Delete the inconsistent license and above line and rerun pre-commit to insert a good license.
+// Copyright 2022 The Parca Authors
// Licensed under the Apache License, Version 2.0 (the "License");
// you may not use this file except in compliance with the License.
// You may obtain a copy of the License at
diff --git a/ui/packages/shared/profile/src/hooks/useQueryState.test.tsx b/ui/packages/shared/profile/src/hooks/useQueryState.test.tsx
index ada373144f3..bdefcc869fb 100644
--- a/ui/packages/shared/profile/src/hooks/useQueryState.test.tsx
+++ b/ui/packages/shared/profile/src/hooks/useQueryState.test.tsx
@@ -17,7 +17,7 @@ import {ReactNode, act} from 'react';
import {QueryClient, QueryClientProvider} from '@tanstack/react-query';
// eslint-disable-next-line import/named
import {renderHook, waitFor} from '@testing-library/react';
-import {NuqsTestingAdapter} from 'nuqs/adapters/testing';
+import {NuqsTestingAdapter} from 'nuqs/dist/adapters/testing';
import {beforeEach, describe, expect, it, vi} from 'vitest';
import {useQueryState} from './useQueryState';
@@ -1051,9 +1051,9 @@ describe('useQueryState', () => {
// Get the committed state values to build reload URL
const historyParams = result1.current.profileSelection?.HistoryParams();
- const selectionA = String(historyParams?.selection ?? '');
- const mergeFromA = String(historyParams?.merge_from ?? '');
- const mergeToA = String(historyParams?.merge_to ?? '');
+ const selectionA = historyParams?.selection ?? '';
+ const mergeFromA = historyParams?.merge_from ?? '';
+ const mergeToA = historyParams?.merge_to ?? '';
unmount();
mockNavigateTo.mockClear();
@@ -1062,9 +1062,10 @@ describe('useQueryState', () => {
const {result: result2} = renderHook(() => useQueryState({suffix: '_a'}), {
wrapper: createWrapper(
{},
- `?selection_a=${encodeURIComponent(
- selectionA
- )}&merge_from_a=${mergeFromA}&merge_to_a=${mergeToA}`
+ // eslint-disable-next-line @typescript-eslint/restrict-template-expressions
+ `?selection_a=${encodeURIComponent(selectionA)}&merge_from_a=${
+ mergeFromA as string
+ }&merge_to_a=${mergeToA as string}`
),
});
From 8f38e3db21e405986f6a278232680daf1d20af73 Mon Sep 17 00:00:00 2001
From: Manoj Vivek
Date: Wed, 8 Apr 2026 10:06:18 +0530
Subject: [PATCH 5/7] Linter fixes
---
.../ProfileFilters/useProfileFiltersUrlState.test.tsx | 3 ++-
ui/packages/shared/profile/src/hooks/useQueryState.test.tsx | 3 ++-
2 files changed, 4 insertions(+), 2 deletions(-)
diff --git a/ui/packages/shared/profile/src/ProfileView/components/ProfileFilters/useProfileFiltersUrlState.test.tsx b/ui/packages/shared/profile/src/ProfileView/components/ProfileFilters/useProfileFiltersUrlState.test.tsx
index 65598d1858c..b0311240758 100644
--- a/ui/packages/shared/profile/src/ProfileView/components/ProfileFilters/useProfileFiltersUrlState.test.tsx
+++ b/ui/packages/shared/profile/src/ProfileView/components/ProfileFilters/useProfileFiltersUrlState.test.tsx
@@ -15,7 +15,8 @@ import {type ReactNode} from 'react';
// eslint-disable-next-line import/named
import {act, renderHook, waitFor} from '@testing-library/react';
-import {NuqsTestingAdapter, type OnUrlUpdateFunction} from 'nuqs/dist/adapters/testing';
+// eslint-disable-next-line import/no-unresolved
+import {NuqsTestingAdapter, type OnUrlUpdateFunction} from 'nuqs/adapters/testing';
import {describe, expect, it, vi} from 'vitest';
import {type ProfileFilter} from './useProfileFilters';
diff --git a/ui/packages/shared/profile/src/hooks/useQueryState.test.tsx b/ui/packages/shared/profile/src/hooks/useQueryState.test.tsx
index bdefcc869fb..d7565299326 100644
--- a/ui/packages/shared/profile/src/hooks/useQueryState.test.tsx
+++ b/ui/packages/shared/profile/src/hooks/useQueryState.test.tsx
@@ -17,7 +17,8 @@ import {ReactNode, act} from 'react';
import {QueryClient, QueryClientProvider} from '@tanstack/react-query';
// eslint-disable-next-line import/named
import {renderHook, waitFor} from '@testing-library/react';
-import {NuqsTestingAdapter} from 'nuqs/dist/adapters/testing';
+// eslint-disable-next-line import/no-unresolved
+import {NuqsTestingAdapter} from 'nuqs/adapters/testing';
import {beforeEach, describe, expect, it, vi} from 'vitest';
import {useQueryState} from './useQueryState';
From 57933623713726eee9ed9f92c620e45c10b61e6a Mon Sep 17 00:00:00 2001
From: Manoj Vivek
Date: Wed, 8 Apr 2026 10:39:15 +0530
Subject: [PATCH 6/7] Dashboard items into a hook with default value from
ParcaContext
---
.../components/src/ParcaContext/index.tsx | 2 +
.../useGraphTooltipMetaInfo/index.ts | 10 ++--
.../FlameGraphArrow/ContextMenu.tsx | 14 +++---
.../components/ViewSelector/index.tsx | 18 ++++----
.../ProfileView/context/DashboardContext.tsx | 17 ++-----
.../profile/src/ProfileViewWithData.tsx | 9 ++--
.../shared/profile/src/Table/MoreDropdown.tsx | 10 ++--
.../profile/src/Table/TableContextMenu.tsx | 10 ++--
.../shared/profile/src/Table/index.tsx | 5 +-
.../shared/profile/src/TopTable/index.tsx | 5 +-
.../shared/profile/src/hooks/urlParsers.ts | 1 -
.../profile/src/hooks/useDashboardItems.ts | 46 +++++++++++++++++++
12 files changed, 86 insertions(+), 61 deletions(-)
create mode 100644 ui/packages/shared/profile/src/hooks/useDashboardItems.ts
diff --git a/ui/packages/shared/components/src/ParcaContext/index.tsx b/ui/packages/shared/components/src/ParcaContext/index.tsx
index 4214eb61145..da01c824d01 100644
--- a/ui/packages/shared/components/src/ParcaContext/index.tsx
+++ b/ui/packages/shared/components/src/ParcaContext/index.tsx
@@ -101,6 +101,7 @@ interface Props {
flamechartHelpText?: ReactNode;
additionalMetricsGraph?: (props: AdditionalMetricsGraphProps) => ReactNode;
enableFlamechartFiltering?: boolean;
+ defaultDashboardItems?: string[];
}
export const defaultValue: Props = {
@@ -129,6 +130,7 @@ export const defaultValue: Props = {
enableSandwichView: false,
isDarkMode: false,
preferencesModal: false,
+ defaultDashboardItems: ['flamegraph'],
};
const ParcaContext = createContext(defaultValue);
diff --git a/ui/packages/shared/profile/src/GraphTooltipArrow/useGraphTooltipMetaInfo/index.ts b/ui/packages/shared/profile/src/GraphTooltipArrow/useGraphTooltipMetaInfo/index.ts
index 2811898b384..e01f5ac6e06 100644
--- a/ui/packages/shared/profile/src/GraphTooltipArrow/useGraphTooltipMetaInfo/index.ts
+++ b/ui/packages/shared/profile/src/GraphTooltipArrow/useGraphTooltipMetaInfo/index.ts
@@ -31,7 +31,8 @@ import {
import {arrowToString} from '../../ProfileFlameGraph/FlameGraphArrow/utils';
import {ProfileSource} from '../../ProfileSource';
import {useProfileViewContext} from '../../ProfileView/context/ProfileViewContext';
-import {dashboardItemsParser, stringParam} from '../../hooks/urlParsers';
+import {stringParam} from '../../hooks/urlParsers';
+import {useDashboardItems} from '../../hooks/useDashboardItems';
import {useQuery} from '../../useQuery';
interface Props {
@@ -109,10 +110,7 @@ export const useGraphTooltipMetaInfo = ({table, row}: Props): GraphTooltipMetaIn
])
.filter(value => value[1] !== '') as Array<[string, string]>;
- const [dashboardItems, setDashboardItems] = useQueryState(
- 'dashboard_items',
- dashboardItemsParser
- );
+ const {dashboardItems, setDashboardItems} = useDashboardItems();
const [_unusedBuildId, setSourceBuildId] = useQueryState('source_buildid', stringParam);
@@ -121,7 +119,7 @@ export const useGraphTooltipMetaInfo = ({table, row}: Props): GraphTooltipMetaIn
const [_unusedLine, setSourceLine] = useQueryState('source_line', stringParam);
const openFile = (): void => {
- void setDashboardItems([dashboardItems[0], 'source']);
+ setDashboardItems([dashboardItems[0], 'source']);
if (mappingBuildID != null) {
void setSourceBuildId(mappingBuildID);
}
diff --git a/ui/packages/shared/profile/src/ProfileFlameGraph/FlameGraphArrow/ContextMenu.tsx b/ui/packages/shared/profile/src/ProfileFlameGraph/FlameGraphArrow/ContextMenu.tsx
index 3ffed51c2c6..0454cf3eb30 100644
--- a/ui/packages/shared/profile/src/ProfileFlameGraph/FlameGraphArrow/ContextMenu.tsx
+++ b/ui/packages/shared/profile/src/ProfileFlameGraph/FlameGraphArrow/ContextMenu.tsx
@@ -26,7 +26,8 @@ import {getLastItem} from '@parca/utilities';
import {useGraphTooltip} from '../../GraphTooltipArrow/useGraphTooltip';
import {useGraphTooltipMetaInfo} from '../../GraphTooltipArrow/useGraphTooltipMetaInfo';
-import {dashboardItemsParser, stringParam} from '../../hooks/urlParsers';
+import {stringParam} from '../../hooks/urlParsers';
+import {useDashboardItems} from '../../hooks/useDashboardItems';
import {hexifyAddress, truncateString} from '../../utils';
interface ContextMenuProps {
@@ -85,10 +86,7 @@ const ContextMenu = ({
inlined,
} = useGraphTooltipMetaInfo({table, row});
- const [dashboardItems, setDashboardItems] = useQueryState(
- 'dashboard_items',
- dashboardItemsParser
- );
+ const {dashboardItems, setDashboardItems} = useDashboardItems();
const [_sandwichFunctionName, setSandwichFunctionName] = useQueryState(
'sandwich_function_name',
stringParam
@@ -177,9 +175,9 @@ const ContextMenu = ({
id="show-in-table"
onClick={() => {
if (isSandwich) {
- void setDashboardItems(['table']);
+ setDashboardItems(['table']);
} else {
- void setDashboardItems([...dashboardItems, 'table']);
+ setDashboardItems([...dashboardItems, 'table']);
}
}}
>
@@ -204,7 +202,7 @@ const ContextMenu = ({
}
void setSandwichFunctionName(functionName);
- void setDashboardItems([...dashboardItems, 'sandwich']);
+ setDashboardItems([...dashboardItems, 'sandwich']);
hideMenu();
}}
disabled={functionName === '' || functionName == null}
diff --git a/ui/packages/shared/profile/src/ProfileView/components/ViewSelector/index.tsx b/ui/packages/shared/profile/src/ProfileView/components/ViewSelector/index.tsx
index 4f1f4c4ecec..296ce6d4065 100644
--- a/ui/packages/shared/profile/src/ProfileView/components/ViewSelector/index.tsx
+++ b/ui/packages/shared/profile/src/ProfileView/components/ViewSelector/index.tsx
@@ -18,7 +18,8 @@ import {useQueryState} from 'nuqs';
import {useParcaContext} from '@parca/components';
import {ProfileSource} from '../../../ProfileSource';
-import {dashboardItemsParser, stringParam} from '../../../hooks/urlParsers';
+import {stringParam} from '../../../hooks/urlParsers';
+import {useDashboardItems} from '../../../hooks/useDashboardItems';
import Dropdown, {DropdownElement, InnerAction} from './Dropdown';
interface Props {
@@ -26,10 +27,7 @@ interface Props {
}
const ViewSelector = ({profileSource}: Props): JSX.Element => {
- const [dashboardItems, setDashboardItems] = useQueryState(
- 'dashboard_items',
- dashboardItemsParser
- );
+ const {dashboardItems, setDashboardItems} = useDashboardItems();
const [, setSandwichFunctionName] = useQueryState('sandwich_function_name', stringParam);
const {enableSourcesView, enableSandwichView} = useParcaContext();
@@ -125,11 +123,11 @@ const ViewSelector = ({profileSource}: Props): JSX.Element => {
: 'Add Panel',
onClick: () => {
if (item.canBeSelected) {
- void setDashboardItems([...dashboardItems, item.key]);
+ setDashboardItems([...dashboardItems, item.key]);
} else {
const newDashboardItems = dashboardItems.filter(v => v !== item.key);
- void setDashboardItems(newDashboardItems);
+ setDashboardItems(newDashboardItems);
if (item.key === 'sandwich') {
void setSandwichFunctionName(null);
}
@@ -151,18 +149,18 @@ const ViewSelector = ({profileSource}: Props): JSX.Element => {
const isOnlyChart = dashboardItems.length === 1;
if (isOnlyChart && value === 'sandwich') {
- void setDashboardItems([...dashboardItems, value]);
+ setDashboardItems([...dashboardItems, value]);
return;
}
if (isOnlyChart) {
- void setDashboardItems([value]);
+ setDashboardItems([value]);
return;
}
const newDashboardItems = [dashboardItems[0], value];
- void setDashboardItems(newDashboardItems);
+ setDashboardItems(newDashboardItems);
};
return (
diff --git a/ui/packages/shared/profile/src/ProfileView/context/DashboardContext.tsx b/ui/packages/shared/profile/src/ProfileView/context/DashboardContext.tsx
index 57f13d2bc32..d495abddedf 100644
--- a/ui/packages/shared/profile/src/ProfileView/context/DashboardContext.tsx
+++ b/ui/packages/shared/profile/src/ProfileView/context/DashboardContext.tsx
@@ -11,11 +11,12 @@
// See the License for the specific language governing permissions and
// limitations under the License.
-import {FC, PropsWithChildren, createContext, useCallback, useContext} from 'react';
+import {FC, PropsWithChildren, createContext, useContext} from 'react';
import {useQueryState} from 'nuqs';
-import {dashboardItemsParser, stringParam} from '../../hooks/urlParsers';
+import {stringParam} from '../../hooks/urlParsers';
+import {useDashboardItems} from '../../hooks/useDashboardItems';
import {VisualizationType} from '../types/visualization';
interface DashboardContextType {
@@ -28,17 +29,7 @@ interface DashboardContextType {
const DashboardContext = createContext(undefined);
export const DashboardProvider: FC = ({children}) => {
- const [dashboardItems, setRawDashboardItems] = useQueryState(
- 'dashboard_items',
- dashboardItemsParser
- );
-
- const setDashboardItems = useCallback(
- (items: string[]) => {
- void setRawDashboardItems(items);
- },
- [setRawDashboardItems]
- );
+ const {dashboardItems, setDashboardItems} = useDashboardItems();
const [, setSandwichFunctionName] = useQueryState('sandwich_function_name', stringParam);
const handleClosePanel = (visualizationType: VisualizationType): void => {
diff --git a/ui/packages/shared/profile/src/ProfileViewWithData.tsx b/ui/packages/shared/profile/src/ProfileViewWithData.tsx
index 69e02bc7a44..9899d402928 100644
--- a/ui/packages/shared/profile/src/ProfileViewWithData.tsx
+++ b/ui/packages/shared/profile/src/ProfileViewWithData.tsx
@@ -31,13 +31,13 @@ import {ProfileView} from './ProfileView';
import {useProfileFilters} from './ProfileView/components/ProfileFilters/useProfileFilters';
import type {SamplesSeries} from './ProfileView/types/visualization';
import {
- dashboardItemsParser,
flamechartDimensionParser,
groupByParser,
intParam,
invertCallStackParser,
stringParam,
} from './hooks/urlParsers';
+import {useDashboardItems} from './hooks/useDashboardItems';
import {useQuery} from './useQuery';
import {downloadPprof} from './utils';
@@ -56,10 +56,7 @@ export const ProfileViewWithData = ({
onSwitchToFifteenMinutes,
}: ProfileViewWithDataProps): JSX.Element => {
const metadata = useGrpcMetadata();
- const [dashboardItems, setDashboardItems] = useQueryState(
- 'dashboard_items',
- dashboardItemsParser
- );
+ const {dashboardItems, setDashboardItems} = useDashboardItems();
const [sourceBuildID] = useQueryState('source_buildid', stringParam);
const [sourceFilename] = useQueryState('source_filename', stringParam);
const [groupBy] = useQueryState('group_by', groupByParser.withDefault([FIELD_FUNCTION_NAME]));
@@ -88,7 +85,7 @@ export const ProfileViewWithData = ({
if (newDashboardItems.length === 0) {
newDashboardItems = ['flamegraph'];
}
- void setDashboardItems(newDashboardItems);
+ setDashboardItems(newDashboardItems);
}, [profileSource, dashboardItems, setDashboardItems]);
const nodeTrimThreshold = useMemo(() => {
diff --git a/ui/packages/shared/profile/src/Table/MoreDropdown.tsx b/ui/packages/shared/profile/src/Table/MoreDropdown.tsx
index ee730ca47e9..bcbcd4a103e 100644
--- a/ui/packages/shared/profile/src/Table/MoreDropdown.tsx
+++ b/ui/packages/shared/profile/src/Table/MoreDropdown.tsx
@@ -17,19 +17,17 @@ import {useQueryState} from 'nuqs';
import {useParcaContext} from '@parca/components';
-import {dashboardItemsParser, stringParam} from '../hooks/urlParsers';
+import {stringParam} from '../hooks/urlParsers';
+import {useDashboardItems} from '../hooks/useDashboardItems';
const MoreDropdown = ({functionName}: {functionName: string}): React.JSX.Element | null => {
const [_, setSandwichFunctionName] = useQueryState('sandwich_function_name', stringParam);
- const [dashboardItems, setDashboardItems] = useQueryState(
- 'dashboard_items',
- dashboardItemsParser
- );
+ const {dashboardItems, setDashboardItems} = useDashboardItems();
const {enableSandwichView} = useParcaContext();
const onSandwichViewSelect = (): void => {
void setSandwichFunctionName(functionName.trim());
- void setDashboardItems([...dashboardItems, 'sandwich']);
+ setDashboardItems([...dashboardItems, 'sandwich']);
};
const menuItems: Array<{label: string; action: () => void}> = [];
diff --git a/ui/packages/shared/profile/src/Table/TableContextMenu.tsx b/ui/packages/shared/profile/src/Table/TableContextMenu.tsx
index 3292065aae5..e6ae1451469 100644
--- a/ui/packages/shared/profile/src/Table/TableContextMenu.tsx
+++ b/ui/packages/shared/profile/src/Table/TableContextMenu.tsx
@@ -23,7 +23,8 @@ import {valueFormatter} from '@parca/utilities';
import {type Row} from '.';
import {getTextForCumulative} from '../ProfileFlameGraph/FlameGraphArrow/utils';
-import {dashboardItemsParser, stringParam} from '../hooks/urlParsers';
+import {stringParam} from '../hooks/urlParsers';
+import {useDashboardItems} from '../hooks/useDashboardItems';
import {truncateString} from '../utils';
import {type ColumnName} from './utils/functions';
@@ -45,17 +46,14 @@ const TableContextMenu = ({
columnVisibility,
}: TableContextMenuProps): React.JSX.Element => {
const [_, setSandwichFunctionName] = useQueryState('sandwich_function_name', stringParam);
- const [dashboardItems, setDashboardItems] = useQueryState(
- 'dashboard_items',
- dashboardItemsParser
- );
+ const {dashboardItems, setDashboardItems} = useDashboardItems();
const {enableSandwichView, isDarkMode} = useParcaContext();
const onSandwichViewSelect = (): void => {
if (row?.name != null && row.name.length > 0) {
void setSandwichFunctionName(row.name.trim());
if (!dashboardItems.includes('sandwich')) {
- void setDashboardItems([...dashboardItems, 'sandwich']);
+ setDashboardItems([...dashboardItems, 'sandwich']);
}
}
};
diff --git a/ui/packages/shared/profile/src/Table/index.tsx b/ui/packages/shared/profile/src/Table/index.tsx
index 15c60ef22b4..cdf8ba984d7 100644
--- a/ui/packages/shared/profile/src/Table/index.tsx
+++ b/ui/packages/shared/profile/src/Table/index.tsx
@@ -27,8 +27,9 @@ import useMappingList, {
useFilenamesList,
} from '../ProfileFlameGraph/FlameGraphArrow/useMappingList';
import {useProfileViewContext} from '../ProfileView/context/ProfileViewContext';
-import {dashboardItemsParser, stringParam} from '../hooks/urlParsers';
+import {stringParam} from '../hooks/urlParsers';
import {useColorBy} from '../hooks/useColorBy';
+import {useDashboardItems} from '../hooks/useDashboardItems';
import {alignedUint8Array} from '../utils';
import TableContextMenuWrapper, {TableContextMenuWrapperRef} from './TableContextMenuWrapper';
import {useColorManagement} from './hooks/useColorManagement';
@@ -74,7 +75,7 @@ export const Table = React.memo(function Table({
error,
}: TableProps): React.JSX.Element {
const currentColorProfile = useCurrentColorProfile();
- const [dashboardItems] = useQueryState('dashboard_items', dashboardItemsParser);
+ const {dashboardItems} = useDashboardItems();
const [_, setSandwichFunctionName] = useQueryState('sandwich_function_name', stringParam);
const {colorBy, setColorBy} = useColorBy();
const {isDarkMode} = useParcaContext();
diff --git a/ui/packages/shared/profile/src/TopTable/index.tsx b/ui/packages/shared/profile/src/TopTable/index.tsx
index b12689e5c4c..321d944a742 100644
--- a/ui/packages/shared/profile/src/TopTable/index.tsx
+++ b/ui/packages/shared/profile/src/TopTable/index.tsx
@@ -14,7 +14,6 @@
import React, {useCallback, useEffect, useMemo} from 'react';
import {createColumnHelper, type ColumnDef} from '@tanstack/react-table';
-import {useQueryState} from 'nuqs';
import {Top, TopNode, TopNodeMeta} from '@parca/client';
import {Button, Table} from '@parca/components';
@@ -27,7 +26,7 @@ import {
} from '@parca/utilities';
import {useProfileViewContext} from '../ProfileView/context/ProfileViewContext';
-import {dashboardItemsParser} from '../hooks/urlParsers';
+import {useDashboardItems} from '../hooks/useDashboardItems';
import {hexifyAddress} from '../utils';
interface TopTableProps {
@@ -74,7 +73,7 @@ export const TopTable = React.memo(function TopTable({
setActionButtons,
}: TopTableProps): JSX.Element {
const router = parseParams(window?.location.search);
- const [dashboardItems] = useQueryState('dashboard_items', dashboardItemsParser);
+ const {dashboardItems} = useDashboardItems();
const {compareMode} = useProfileViewContext();
diff --git a/ui/packages/shared/profile/src/hooks/urlParsers.ts b/ui/packages/shared/profile/src/hooks/urlParsers.ts
index 81e84064ea5..efff6e858f6 100644
--- a/ui/packages/shared/profile/src/hooks/urlParsers.ts
+++ b/ui/packages/shared/profile/src/hooks/urlParsers.ts
@@ -23,7 +23,6 @@ export const commaArrayParam = parseAsArrayOf(parseAsString, ',').withOptions(op
// === Param-specific parsers with defaults ===
export const invertCallStackParser = boolParam.withDefault(false);
-export const dashboardItemsParser = commaArrayParam.withDefault(['flamegraph']);
export const groupByParser = commaArrayParam;
export const flamechartDimensionParser = commaArrayParam;
export const tableColumnsParser = commaArrayParam;
diff --git a/ui/packages/shared/profile/src/hooks/useDashboardItems.ts b/ui/packages/shared/profile/src/hooks/useDashboardItems.ts
new file mode 100644
index 00000000000..611424a5c77
--- /dev/null
+++ b/ui/packages/shared/profile/src/hooks/useDashboardItems.ts
@@ -0,0 +1,46 @@
+// Copyright 2022 The Parca Authors
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+import {useCallback, useMemo} from 'react';
+
+import {parseAsArrayOf, parseAsString, useQueryState} from 'nuqs';
+
+import {useParcaContext} from '@parca/components';
+
+const opts = {history: 'replace' as const};
+
+export const useDashboardItems = (): {
+ dashboardItems: string[];
+ setDashboardItems: (items: string[]) => void;
+} => {
+ const {defaultDashboardItems} = useParcaContext();
+
+ const parser = useMemo(
+ () =>
+ parseAsArrayOf(parseAsString, ',')
+ .withDefault(defaultDashboardItems ?? ['flamegraph'])
+ .withOptions(opts),
+ [defaultDashboardItems]
+ );
+
+ const [dashboardItems, setRawDashboardItems] = useQueryState('dashboard_items', parser);
+
+ const setDashboardItems = useCallback(
+ (items: string[]) => {
+ void setRawDashboardItems(items);
+ },
+ [setRawDashboardItems]
+ );
+
+ return {dashboardItems, setDashboardItems};
+};
From d804240b27e67d6ab5fe7c6a5b14cf438aa879d4 Mon Sep 17 00:00:00 2001
From: Manoj Vivek
Date: Wed, 8 Apr 2026 10:39:37 +0530
Subject: [PATCH 7/7] Dashboard items into a hook with default value from
ParcaContext
---
ui/packages/shared/profile/src/index.tsx | 16 ++++++++++++++++
1 file changed, 16 insertions(+)
diff --git a/ui/packages/shared/profile/src/index.tsx b/ui/packages/shared/profile/src/index.tsx
index 05ea6982348..cb15e520d81 100644
--- a/ui/packages/shared/profile/src/index.tsx
+++ b/ui/packages/shared/profile/src/index.tsx
@@ -60,6 +60,22 @@ export {useProfileFiltersUrlState} from './ProfileView/components/ProfileFilters
export {useProfileTypes} from './ProfileSelector';
+export {
+ stringParam,
+ boolParam,
+ intParam,
+ commaArrayParam,
+ invertCallStackParser,
+ groupByParser,
+ flamechartDimensionParser,
+ tableColumnsParser,
+ hiddenBinariesParser,
+ jsonParser,
+} from './hooks/urlParsers';
+
+export {useDashboardItems} from './hooks/useDashboardItems';
+export {useColorBy} from './hooks/useColorBy';
+
export {
ProfileExplorer,
ProfileTypeSelector,