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,