From 0050b0a505fa68d326f163a2b0eef9869bc6b372 Mon Sep 17 00:00:00 2001 From: Tom Alexander Date: Mon, 23 Feb 2026 15:48:20 -0500 Subject: [PATCH 1/4] chore: upgrade nuqs to v2 and add NuqsAdapter for Pages Router nuqs v2 requires NuqsAdapter to be added at the app root when using the Next.js Pages Router. The parseAsStringWithNewLines workaround (patching newline encoding) is no longer needed as v2.2.3+ handles this natively. Co-authored-by: Cursor --- packages/app/package.json | 2 +- packages/app/pages/_app.tsx | 19 +++++++++++-------- yarn.lock | 34 +++++++++++++++++++++++++--------- 3 files changed, 37 insertions(+), 18 deletions(-) diff --git a/packages/app/package.json b/packages/app/package.json index 1e719baa3..bf7099624 100644 --- a/packages/app/package.json +++ b/packages/app/package.json @@ -72,7 +72,7 @@ "next-runtime-env": "1", "next-seo": "^4.28.1", "numbro": "^2.4.0", - "nuqs": "1.17.0", + "nuqs": "^2.8.8", "object-hash": "^3.0.0", "react": "^19.2.3", "react-copy-to-clipboard": "^5.1.0", diff --git a/packages/app/pages/_app.tsx b/packages/app/pages/_app.tsx index 9c4aa2da4..b6cf372e4 100644 --- a/packages/app/pages/_app.tsx +++ b/packages/app/pages/_app.tsx @@ -5,6 +5,7 @@ import Head from 'next/head'; import { NextAdapter } from 'next-query-params'; import randomUUID from 'crypto-randomuuid'; import { enableMapSet } from 'immer'; +import { NuqsAdapter } from 'nuqs/adapters/next/pages'; import { QueryParamProvider } from 'use-query-params'; import HyperDX from '@hyperdx/browser'; import { @@ -175,14 +176,16 @@ export default function MyApp({ Component, pageProps }: AppPropsWithLayout) { - - - - - - - - + + + + + + + + + + ); diff --git a/yarn.lock b/yarn.lock index 262a53493..1824a2c1f 100644 --- a/yarn.lock +++ b/yarn.lock @@ -4389,7 +4389,7 @@ __metadata: next-runtime-env: "npm:1" next-seo: "npm:^4.28.1" numbro: "npm:^2.4.0" - nuqs: "npm:1.17.0" + nuqs: "npm:^2.8.8" object-hash: "npm:^3.0.0" postcss: "npm:^8.4.38" postcss-preset-mantine: "npm:^1.15.0" @@ -8058,7 +8058,7 @@ __metadata: languageName: node linkType: hard -"@standard-schema/spec@npm:^1.0.0": +"@standard-schema/spec@npm:1.0.0, @standard-schema/spec@npm:^1.0.0": version: 1.0.0 resolution: "@standard-schema/spec@npm:1.0.0" checksum: 10c0/a1ab9a8bdc09b5b47aa8365d0e0ec40cc2df6437be02853696a0e377321653b0d3ac6f079a8c67d5ddbe9821025584b1fb71d9cc041a6666a96f1fadf2ece15f @@ -20044,7 +20044,7 @@ __metadata: languageName: node linkType: hard -"mitt@npm:^3.0.0, mitt@npm:^3.0.1": +"mitt@npm:^3.0.0": version: 3.0.1 resolution: "mitt@npm:3.0.1" checksum: 10c0/3ab4fdecf3be8c5255536faa07064d05caa3dd332bd318ff02e04621f7b3069ca1de9106cfe8e7ced675abfc2bec2ce4c4ef321c4a1bb1fb29df8ae090741913 @@ -20665,14 +20665,30 @@ __metadata: languageName: node linkType: hard -"nuqs@npm:1.17.0": - version: 1.17.0 - resolution: "nuqs@npm:1.17.0" +"nuqs@npm:^2.8.8": + version: 2.8.8 + resolution: "nuqs@npm:2.8.8" dependencies: - mitt: "npm:^3.0.1" + "@standard-schema/spec": "npm:1.0.0" peerDependencies: - next: ">=13.4 <14.0.2 || ^14.0.3" - checksum: 10c0/98e26518fdeda9518defe8abb34fd722f9fbc6d62dc7713dbc7bf365b5afc4c7c934088d7c7ed528621c222017841411241f0cfe97c615a3e7fb4d80354e8349 + "@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 + checksum: 10c0/d3a5503268d5a297c5b5acbd0c6f362f7462f67fea31f88264748ccab7788343bd428b49b7e63f5c5be08afcb22a2ee569fd36b6d0b8f95b8cfca7013ea700e3 languageName: node linkType: hard From ce5319b2b2aeb442e8c663383eeefc463dfdf13f Mon Sep 17 00:00:00 2001 From: Tom Alexander Date: Mon, 23 Feb 2026 16:11:12 -0500 Subject: [PATCH 2/4] chore: remove use-query-params in favour of nuqs, enable skipped tests - Migrate all useQueryParam/useQueryParams calls to nuqs useQueryState/useQueryStates - Remove use-query-params, next-query-params, and serialize-query-params packages - Remove @jedmao/location (test utility no longer needed) - Delete custom useQueryParam.tsx hook and fixtures.ts (replaced by NuqsTestingAdapter) - Strip QueryParamProvider/NextAdapter/HDXQueryParamProvider from _app.tsx and storybook - Update jest.config.js to transform nuqs (ESM package) for unit tests - Rewrite timeQuery.test.tsx using NuqsTestingAdapter and remove describe.skip Co-authored-by: Cursor --- packages/app/.storybook/preview.tsx | 11 +- packages/app/jest.config.js | 2 +- packages/app/package.json | 4 - packages/app/pages/_app.tsx | 15 +- packages/app/src/DBDashboardImportPage.tsx | 4 +- .../app/src/NamespaceDetailsSidePanel.tsx | 9 +- packages/app/src/NodeDetailsSidePanel.tsx | 9 +- packages/app/src/PodDetailsSidePanel.tsx | 13 +- packages/app/src/SessionsPage.tsx | 22 +- packages/app/src/__tests__/timeQuery.test.tsx | 261 +++++------------- packages/app/src/components/AppNav/AppNav.tsx | 22 +- packages/app/src/fixtures.ts | 53 ---- packages/app/src/timeQuery.ts | 34 +-- packages/app/src/useQueryParam.tsx | 103 ------- yarn.lock | 43 --- 15 files changed, 124 insertions(+), 481 deletions(-) delete mode 100644 packages/app/src/fixtures.ts delete mode 100644 packages/app/src/useQueryParam.tsx diff --git a/packages/app/.storybook/preview.tsx b/packages/app/.storybook/preview.tsx index 6ac5a5403..59fd244c3 100644 --- a/packages/app/.storybook/preview.tsx +++ b/packages/app/.storybook/preview.tsx @@ -1,7 +1,6 @@ import React from 'react'; -import { NextAdapter } from 'next-query-params'; import { initialize, mswLoader } from 'msw-storybook-addon'; -import { QueryParamProvider } from 'use-query-params'; +import { NuqsAdapter } from 'nuqs/adapters/next/pages'; import type { Preview } from '@storybook/nextjs'; import { QueryClient, QueryClientProvider } from '@tanstack/react-query'; @@ -88,16 +87,16 @@ const preview: Preview = { return (
- - + + - - + +
); }, diff --git a/packages/app/jest.config.js b/packages/app/jest.config.js index f45dc96e0..17d99f788 100644 --- a/packages/app/jest.config.js +++ b/packages/app/jest.config.js @@ -14,7 +14,7 @@ module.exports = { globalSetup: '/global-setup.js', moduleFileExtensions: ['ts', 'tsx', 'js', 'jsx', 'json', 'node'], testRegex: '(/__tests__/.*|(\\.|/)(test|spec))\\.(jsx?|tsx?)$', - transformIgnorePatterns: ['/node_modules/'], + transformIgnorePatterns: ['/node_modules/(?!(nuqs)/)'], moduleNameMapper: { '\\.(css|less|scss|sass)$': 'identity-obj-proxy', '^@/(.*)$': '/src/$1', diff --git a/packages/app/package.json b/packages/app/package.json index bf7099624..22db14a17 100644 --- a/packages/app/package.json +++ b/packages/app/package.json @@ -68,7 +68,6 @@ "lodash": "^4.17.21", "ms": "^2.1.3", "next": "^16.1.5", - "next-query-params": "^4.3.1", "next-runtime-env": "1", "next-seo": "^4.28.1", "numbro": "^2.4.0", @@ -91,7 +90,6 @@ "recharts": "^2.12.7", "rrweb": "2.0.0-alpha.8", "sass": "^1.54.8", - "serialize-query-params": "^2.0.2", "sql-formatter": "^15.4.0", "sqlstring": "^2.3.3", "store2": "^2.14.3", @@ -99,14 +97,12 @@ "timestamp-nano": "^1.0.1", "uplot": "^1.6.31", "uplot-react": "^1.2.2", - "use-query-params": "^2.1.2", "zod": "3.25" }, "devDependencies": { "@chromatic-com/storybook": "^4.1.3", "@eslint/compat": "^2.0.0", "@hookform/devtools": "^4.3.1", - "@jedmao/location": "^3.0.0", "@playwright/test": "^1.57.0", "@storybook/addon-docs": "^10.1.4", "@storybook/addon-links": "^10.1.4", diff --git a/packages/app/pages/_app.tsx b/packages/app/pages/_app.tsx index b6cf372e4..88dbceb60 100644 --- a/packages/app/pages/_app.tsx +++ b/packages/app/pages/_app.tsx @@ -2,11 +2,9 @@ import React, { useEffect } from 'react'; import type { NextPage } from 'next'; import type { AppProps } from 'next/app'; import Head from 'next/head'; -import { NextAdapter } from 'next-query-params'; import randomUUID from 'crypto-randomuuid'; import { enableMapSet } from 'immer'; import { NuqsAdapter } from 'nuqs/adapters/next/pages'; -import { QueryParamProvider } from 'use-query-params'; import HyperDX from '@hyperdx/browser'; import { MutationCache, @@ -28,7 +26,6 @@ import { AppThemeProvider, useAppTheme } from '@/theme/ThemeProvider'; import { ThemeWrapper } from '@/ThemeWrapper'; import { NextApiConfigResponseData } from '@/types'; import { ConfirmProvider } from '@/useConfirm'; -import { QueryParamProvider as HDXQueryParamProvider } from '@/useQueryParam'; import { SystemColorSchemeScript, useResolvedColorScheme, @@ -177,14 +174,10 @@ export default function MyApp({ Component, pageProps }: AppPropsWithLayout) { - - - - - - - - + + + + diff --git a/packages/app/src/DBDashboardImportPage.tsx b/packages/app/src/DBDashboardImportPage.tsx index 2e46a6b7d..021f97662 100644 --- a/packages/app/src/DBDashboardImportPage.tsx +++ b/packages/app/src/DBDashboardImportPage.tsx @@ -3,8 +3,8 @@ import dynamic from 'next/dynamic'; import Head from 'next/head'; import { useRouter } from 'next/router'; import { filter } from 'lodash'; +import { useQueryState } from 'nuqs'; import { Controller, useForm, useWatch } from 'react-hook-form'; -import { StringParam, useQueryParam } from 'use-query-params'; import { z } from 'zod'; import { zodResolver } from '@hookform/resolvers/zod'; import { convertToDashboardDocument } from '@hyperdx/common-utils/dist/core/utils'; @@ -190,7 +190,7 @@ type SourceResolutionFormValues = z.infer; function Mapping({ input }: { input: Input }) { const router = useRouter(); const { data: sources } = useSources(); - const [dashboardId] = useQueryParam('dashboardId', StringParam); + const [dashboardId] = useQueryState('dashboardId'); const { handleSubmit, getFieldState, control, setValue } = useForm({ diff --git a/packages/app/src/NamespaceDetailsSidePanel.tsx b/packages/app/src/NamespaceDetailsSidePanel.tsx index ce0967e2d..d75ed2c45 100644 --- a/packages/app/src/NamespaceDetailsSidePanel.tsx +++ b/packages/app/src/NamespaceDetailsSidePanel.tsx @@ -1,5 +1,5 @@ import * as React from 'react'; -import { StringParam, useQueryParam, withDefault } from 'use-query-params'; +import { parseAsString, useQueryState } from 'nuqs'; import { tcFromSource } from '@hyperdx/common-utils/dist/core/metadata'; import { convertDateRangeToGranularityString } from '@hyperdx/common-utils/dist/core/utils'; import { TSource } from '@hyperdx/common-utils/dist/types'; @@ -229,12 +229,9 @@ export default function NamespaceDetailsSidePanel({ metricSource: TSource; logSource: TSource; }) { - const [namespaceName, setNamespaceName] = useQueryParam( + const [namespaceName, setNamespaceName] = useQueryState( 'namespaceName', - withDefault(StringParam, ''), - { - updateType: 'replaceIn', - }, + parseAsString.withDefault(''), ); const contextZIndex = useZIndex(); diff --git a/packages/app/src/NodeDetailsSidePanel.tsx b/packages/app/src/NodeDetailsSidePanel.tsx index 4dfc2cab2..fafbeadca 100644 --- a/packages/app/src/NodeDetailsSidePanel.tsx +++ b/packages/app/src/NodeDetailsSidePanel.tsx @@ -1,5 +1,5 @@ import * as React from 'react'; -import { StringParam, useQueryParam, withDefault } from 'use-query-params'; +import { parseAsString, useQueryState } from 'nuqs'; import { tcFromSource } from '@hyperdx/common-utils/dist/core/metadata'; import { convertDateRangeToGranularityString } from '@hyperdx/common-utils/dist/core/utils'; import { TSource } from '@hyperdx/common-utils/dist/types'; @@ -242,12 +242,9 @@ export default function NodeDetailsSidePanel({ metricSource: TSource; logSource: TSource; }) { - const [nodeName, setNodeName] = useQueryParam( + const [nodeName, setNodeName] = useQueryState( 'nodeName', - withDefault(StringParam, ''), - { - updateType: 'replaceIn', - }, + parseAsString.withDefault(''), ); const contextZIndex = useZIndex(); diff --git a/packages/app/src/PodDetailsSidePanel.tsx b/packages/app/src/PodDetailsSidePanel.tsx index bc32115e1..b3d5a7e28 100644 --- a/packages/app/src/PodDetailsSidePanel.tsx +++ b/packages/app/src/PodDetailsSidePanel.tsx @@ -1,5 +1,5 @@ import * as React from 'react'; -import { StringParam, useQueryParam, withDefault } from 'use-query-params'; +import { parseAsString, useQueryState } from 'nuqs'; import { tcFromSource } from '@hyperdx/common-utils/dist/core/metadata'; import { convertDateRangeToGranularityString } from '@hyperdx/common-utils/dist/core/utils'; import { TSource } from '@hyperdx/common-utils/dist/types'; @@ -223,12 +223,9 @@ export default function PodDetailsSidePanel({ logSource: TSource; metricSource: TSource; }) { - const [podName, setPodName] = useQueryParam( + const [podName, setPodName] = useQueryState( 'podName', - withDefault(StringParam, ''), - { - updateType: 'replaceIn', - }, + parseAsString.withDefault(''), ); const [rowId, setRowId] = React.useState(null); @@ -243,8 +240,8 @@ export default function PodDetailsSidePanel({ // If we're in a nested side panel, we need to use a higher z-index // TODO: This is a hack - const [nodeName] = useQueryParam('nodeName', StringParam); - const [namespaceName] = useQueryParam('namespaceName', StringParam); + const [nodeName] = useQueryState('nodeName'); + const [namespaceName] = useQueryState('namespaceName'); const isNested = !!nodeName || !!namespaceName; const contextZIndex = useZIndex(); const drawerZIndex = contextZIndex + 10 + (isNested ? 100 : 0); diff --git a/packages/app/src/SessionsPage.tsx b/packages/app/src/SessionsPage.tsx index 46a756f94..26055be87 100644 --- a/packages/app/src/SessionsPage.tsx +++ b/packages/app/src/SessionsPage.tsx @@ -1,10 +1,13 @@ import { useCallback, useEffect, useMemo, useRef, useState } from 'react'; import Head from 'next/head'; import { sub } from 'date-fns'; -import { parseAsString, parseAsStringEnum, useQueryStates } from 'nuqs'; +import { + parseAsInteger, + parseAsString, + parseAsStringEnum, + useQueryStates, +} from 'nuqs'; import { useForm, useWatch } from 'react-hook-form'; -import { NumberParam } from 'serialize-query-params'; -import { StringParam, useQueryParams, withDefault } from 'use-query-params'; import { tcFromSource } from '@hyperdx/common-utils/dist/core/metadata'; import { SearchCondition, @@ -329,16 +332,9 @@ export default function SessionsPage() { [], ); - const [selectedSessionQuery, setSelectedSessionQuery] = useQueryParams( - { - sid: withDefault(StringParam, undefined), - sfrom: withDefault(NumberParam, undefined), - sto: withDefault(NumberParam, undefined), - }, - { - updateType: 'pushIn', - enableBatching: true, - }, + const [selectedSessionQuery, setSelectedSessionQuery] = useQueryStates( + { sid: parseAsString, sfrom: parseAsInteger, sto: parseAsInteger }, + { history: 'push' }, ); const selectedSession = useMemo(() => { diff --git a/packages/app/src/__tests__/timeQuery.test.tsx b/packages/app/src/__tests__/timeQuery.test.tsx index 40cf31c9c..d2af9183d 100644 --- a/packages/app/src/__tests__/timeQuery.test.tsx +++ b/packages/app/src/__tests__/timeQuery.test.tsx @@ -1,12 +1,8 @@ import * as React from 'react'; import { useImperativeHandle } from 'react'; import { useRouter } from 'next/router'; -import { NextAdapter } from 'next-query-params'; -import { QueryParamProvider } from 'use-query-params'; -import { LocationMock } from '@jedmao/location'; -import { render } from '@testing-library/react'; - -import { TestRouter } from '@/fixtures'; +import { NuqsTestingAdapter } from 'nuqs/adapters/testing'; +import { act, render } from '@testing-library/react'; import { getLiveTailTimeRange, @@ -27,17 +23,22 @@ jest.mock('next/router', () => ({ function TestWrapper({ children, isUTC, + searchParams, }: { children: React.ReactNode; isUTC?: boolean; + searchParams?: Record; }) { const { setUserPreference } = useUserPreferences(); React.useEffect(() => { setUserPreference({ isUTC }); }, [setUserPreference, isUTC]); + return ( - {children} + + {children} + ); } @@ -52,37 +53,14 @@ const TestComponent = React.forwardRef(function Component( return null; }); -const { location: savedLocation } = window; - -// TODO: Issues with testing nuqs :( -// https://github.com/47ng/nuqs/issues/259 -describe.skip('useTimeQuery tests', () => { - let testRouter: TestRouter; - let locationMock: LocationMock; - - beforeAll(() => { - // @ts-ignore - This complains because we can only delete optional operands - delete window.location; - }); - +describe('useNewTimeQuery tests', () => { beforeEach(() => { jest.resetAllMocks(); - locationMock = new LocationMock('https://www.hyperdx.io/'); - testRouter = new TestRouter(locationMock); - // @ts-ignore - this is a mock - window.location = locationMock; - - (useRouter as jest.Mock).mockReturnValue(testRouter); - + (useRouter as jest.Mock).mockReturnValue({ isReady: true }); jest.useFakeTimers().setSystemTime(new Date(INITIAL_DATE_STRING)); }); - afterAll(() => { - // @ts-ignore - this is a mock - window.location = savedLocation; - }); - - it('initializes successfully to a non-UTC time', async () => { + it('displays initial time range as a formatted string when no url params', async () => { const timeQueryRef = React.createRef(); render( @@ -94,13 +72,16 @@ describe.skip('useTimeQuery tests', () => { , ); - // The live tail time range is 15 mins + // The live tail time range is 15 mins before the fixed time expect(timeQueryRef.current?.displayedTimeInputValue).toMatchInlineSnapshot( `"Oct 3 11:45:00 - Oct 3 12:00:00"`, ); + expect(timeQueryRef.current?.searchedTimeRange).toEqual( + getLiveTailTimeRange(), + ); }); - it('initializes successfully to a UTC time', async () => { + it('displays initial time range in UTC when isUTC is set', async () => { const timeQueryRef = React.createRef(); render( @@ -112,57 +93,20 @@ describe.skip('useTimeQuery tests', () => { , ); - // The live tail time range is 15 mins expect(timeQueryRef.current?.displayedTimeInputValue).toMatchInlineSnapshot( `"Oct 3 15:45:00 - Oct 3 16:00:00"`, ); }); - it('can be overridden by `tq` url param', async () => { + it('accepts `from` and `to` url params and updates searchedTimeRange', async () => { const timeQueryRef = React.createRef(); - testRouter.replace('/search?tq=Last+4H'); - const { rerender } = render( - - - , - ); - jest.runAllTimers(); - - rerender( - - - , - ); - jest.runAllTimers(); - - // Once the hook runs, it will unset the `tq` param and replace it with - // a `from` and `to` - expect(locationMock.searchParams.get('tq')).toBeNull(); - // `From` should be 10/03/23 at 8:00am EDT - expect(locationMock.searchParams.get('from')).toBe('1696334400000'); - // `To` should be 10/03/23 at 12:00pm EDT - expect(locationMock.searchParams.get('to')).toBe('1696348800000'); - expect(timeQueryRef.current?.displayedTimeInputValue).toMatchInlineSnapshot( - `"Oct 3 08:00:00 - Oct 3 12:00:00"`, - ); - }); - - it('browser navigation of from/to qparmas updates the searched time range', async () => { - const timeQueryRef = React.createRef(); - testRouter.setIsReady(false); - testRouter.replace('/search'); - - const result = render( - + render( + // 10/03/23 from 04:00am EDT to 08:00am EDT + @@ -170,72 +114,23 @@ describe.skip('useTimeQuery tests', () => { ); jest.runAllTimers(); - testRouter.setIsReady(true); - - result.rerender( - - - , - ); - - expect(timeQueryRef.current?.displayedTimeInputValue).toMatchInlineSnapshot( - `"Past 1h"`, - ); - expect(timeQueryRef.current?.searchedTimeRange).toMatchInlineSnapshot(` - Array [ - 2023-10-03T15:45:00.000Z, - 2023-10-03T16:00:00.000Z, - ] - `); - - // 10/03/23 from 04:00am EDT to 08:00am EDT - testRouter.replace('/search?from=1696320000000&to=1696334400000'); - - result.rerender( - - - , - ); - - result.rerender( - - - , - ); - expect(timeQueryRef.current?.displayedTimeInputValue).toMatchInlineSnapshot( `"Oct 3 04:00:00 - Oct 3 08:00:00"`, ); expect(timeQueryRef.current?.searchedTimeRange).toMatchInlineSnapshot(` - Array [ + [ 2023-10-03T08:00:00.000Z, 2023-10-03T12:00:00.000Z, ] `); }); - it('overrides initial value with async updated `from` and `to` params', async () => { + it('falls back to initialTimeRange when url params are invalid', async () => { const timeQueryRef = React.createRef(); - // 10/03/23 from 04:00am EDT to 08:00am EDT - testRouter.setIsReady(false); - testRouter.replace('/search'); - const result = render( - + render( + @@ -243,78 +138,60 @@ describe.skip('useTimeQuery tests', () => { ); jest.runAllTimers(); - testRouter.replace('/search?from=1696320000000&to=1696334400000'); - testRouter.setIsReady(true); - - result.rerender( - - - , - ); - + // nuqs returns null for invalid integers, so the hook falls back to initialTimeRange expect(timeQueryRef.current?.displayedTimeInputValue).toMatchInlineSnapshot( - `"Oct 3 04:00:00 - Oct 3 08:00:00"`, + `"Oct 3 11:45:00 - Oct 3 12:00:00"`, + ); + expect(timeQueryRef.current?.searchedTimeRange).toEqual( + getLiveTailTimeRange(), ); - expect(timeQueryRef.current?.searchedTimeRange).toMatchInlineSnapshot(` - Array [ - 2023-10-03T08:00:00.000Z, - 2023-10-03T12:00:00.000Z, - ] - `); }); - it('accepts `from` and `to` url params', async () => { + it('does not update displayedTimeInputValue until router is ready', async () => { const timeQueryRef = React.createRef(); - // 10/03/23 from 04:00am EDT to 08:00am EDT - testRouter.replace('/search?from=1696320000000&to=1696334400000'); + (useRouter as jest.Mock).mockReturnValue({ isReady: false }); - render( - + // Start with no url params and router not ready + const { rerender } = render( + , ); jest.runAllTimers(); - expect(timeQueryRef.current?.displayedTimeInputValue).toMatchInlineSnapshot( - `"Oct 3 04:00:00 - Oct 3 08:00:00"`, - ); - }); - - it('handles bad input in `from` and `to` url params', async () => { - const timeQueryRef = React.createRef(); - testRouter.replace('/search?from=abc&to=def'); + // While not ready, displayedTimeInputValue stays at initialDisplayValue + expect(timeQueryRef.current?.displayedTimeInputValue).toBe('Past 1h'); - render( - + // Make router ready — useEffect fires and picks up empty URL params + (useRouter as jest.Mock).mockReturnValue({ isReady: true }); + rerender( + , ); jest.runAllTimers(); - // Should initialize to the initial time range 11:45am - 12:00pm - expect(timeQueryRef.current?.displayedTimeInputValue).toMatchInlineSnapshot( - `"Oct 3 11:45:00 - Oct 3 12:00:00"`, + // With showRelativeInterval + no params, keeps initialDisplayValue + expect(timeQueryRef.current?.displayedTimeInputValue).toBe('Past 1h'); + expect(timeQueryRef.current?.searchedTimeRange).toEqual( + getLiveTailTimeRange(), ); }); - it('prefers `tq` param over `from` and `to` params', async () => { + it('updates searchedTimeRange when onTimeRangeSelect is called', async () => { const timeQueryRef = React.createRef(); - // 10/03/23 from 04:00am EDT to 08:00am EDT, tq says last 1 hour - testRouter.replace( - '/search?from=1696320000000&to=1696334400000&tq=Past+1h', - ); - const result = render( + render( { /> , ); - jest.runAllTimers(); - result.rerender( - - - , - ); - jest.runAllTimers(); + // Simulate user selecting a time range + act(() => { + timeQueryRef.current?.onTimeRangeSelect( + // 10/03/23 from 04:00am EDT to 08:00am EDT + new Date(1696320000000), + new Date(1696334400000), + ); + }); - // The time range should be the last 1 hour even though the `from` and `to` - // params are passed in. expect(timeQueryRef.current?.displayedTimeInputValue).toMatchInlineSnapshot( - `"Oct 3 11:00:00 - Oct 3 12:00:00"`, + `"Oct 3 04:00:00 - Oct 3 08:00:00"`, ); + expect(timeQueryRef.current?.searchedTimeRange).toMatchInlineSnapshot(` + [ + 2023-10-03T08:00:00.000Z, + 2023-10-03T12:00:00.000Z, + ] + `); }); - it('enables custom display value', async () => { + it('preserves initialDisplayValue when showRelativeInterval is set', async () => { const timeQueryRef = React.createRef(); - testRouter.replace('/search'); - const initialDisplayValue = 'Live Tail'; + const initialDisplayValue = 'Past 1h'; render( , diff --git a/packages/app/src/components/AppNav/AppNav.tsx b/packages/app/src/components/AppNav/AppNav.tsx index 279abb245..ba207d131 100644 --- a/packages/app/src/components/AppNav/AppNav.tsx +++ b/packages/app/src/components/AppNav/AppNav.tsx @@ -4,12 +4,11 @@ import Router, { useRouter } from 'next/router'; import cx from 'classnames'; import Fuse from 'fuse.js'; import { - NumberParam, - StringParam, - useQueryParam, - useQueryParams, - withDefault, -} from 'use-query-params'; + parseAsInteger, + parseAsString, + useQueryState, + useQueryStates, +} from 'nuqs'; import HyperDX from '@hyperdx/browser'; import { AlertState } from '@hyperdx/common-utils/dist/types'; import { @@ -431,13 +430,12 @@ export default function AppNav({ fixed = false }: { fixed?: boolean }) { const router = useRouter(); const { pathname, query } = router; - const [timeRangeQuery] = useQueryParams({ - from: withDefault(NumberParam, -1), - to: withDefault(NumberParam, -1), + const [timeRangeQuery] = useQueryStates({ + from: parseAsInteger.withDefault(-1), + to: parseAsInteger.withDefault(-1), }); - const [inputTimeQuery] = useQueryParam('tq', withDefault(StringParam, ''), { - updateType: 'pushIn', - enableBatching: true, + const [inputTimeQuery] = useQueryState('tq', parseAsString.withDefault(''), { + history: 'push', }); const { data: meData } = api.useMe(); diff --git a/packages/app/src/fixtures.ts b/packages/app/src/fixtures.ts deleted file mode 100644 index 570f2cc55..000000000 --- a/packages/app/src/fixtures.ts +++ /dev/null @@ -1,53 +0,0 @@ -import { Router } from 'next/router'; -import type { UrlObject } from 'url'; -import { LocationMock } from '@jedmao/location'; - -type PartialRouter = Partial; - -export const BASE_URL = 'https://www.hyperdx.io'; - -/** - * A Router to be used for testing which provides the bare minimum needed - * for the useQueryParam(s) hook and NextAdapter to work. - */ -export class TestRouter implements PartialRouter { - isReady = true; - pathname = '/'; - private currentUrl = ''; - private history: string[] = []; - - constructor(private locationMock: LocationMock) {} - - replace = (url: string | UrlObject) => { - this.locationMock.assign(`${BASE_URL}${url}`); - this.currentUrl = TestRouter.getURLString(url); - this.locationMock.assign(`${BASE_URL}${this.currentUrl}`); - return Promise.resolve(true); - }; - - push = (url: string | UrlObject) => { - this.history.push(this.currentUrl); - this.currentUrl = TestRouter.getURLString(url); - this.locationMock.assign(`${BASE_URL}${this.currentUrl}`); - return Promise.resolve(true); - }; - - setIsReady = (isReady: boolean) => { - this.isReady = isReady; - }; - - get asPath() { - return this.pathname; - } - - static getURLString(url: string | UrlObject): string { - if (typeof url === 'string') { - return url; - } - return `${url.pathname}${url.search}`; - } - - getParams(): URLSearchParams { - return new URL(`${BASE_URL}${this.currentUrl}`).searchParams; - } -} diff --git a/packages/app/src/timeQuery.ts b/packages/app/src/timeQuery.ts index 747df6b7e..1e3e39a12 100644 --- a/packages/app/src/timeQuery.ts +++ b/packages/app/src/timeQuery.ts @@ -16,14 +16,13 @@ import { sub, subMilliseconds, } from 'date-fns'; -import { parseAsFloat, useQueryStates } from 'nuqs'; import { - NumberParam, - StringParam, - useQueryParam, - useQueryParams, - withDefault, -} from 'use-query-params'; + parseAsFloat, + parseAsInteger, + parseAsString, + useQueryState, + useQueryStates, +} from 'nuqs'; import { formatDate } from '@hyperdx/common-utils/dist/core/utils'; import { DateRange } from '@hyperdx/common-utils/dist/types'; @@ -95,15 +94,9 @@ export function useTimeQuery({ undefined | string >(undefined); - const [_timeRangeQuery, setTimeRangeQuery] = useQueryParams( - { - from: withDefault(NumberParam, undefined), - to: withDefault(NumberParam, undefined), - }, - { - updateType: 'pushIn', - enableBatching: true, - }, + const [_timeRangeQuery, setTimeRangeQuery] = useQueryStates( + { from: parseAsInteger, to: parseAsInteger }, + { history: 'push' }, ); const timeRangeQuery = useMemo( @@ -115,13 +108,10 @@ export function useTimeQuery({ ); // Allow browser back/fwd button to modify the displayed time input value - const [inputTimeQuery, setInputTimeQuery] = useQueryParam( + const [inputTimeQuery, setInputTimeQuery] = useQueryState( 'tq', - withDefault(StringParam, ''), - { - updateType: 'pushIn', - enableBatching: true, - }, + parseAsString.withDefault(''), + { history: 'push' }, ); const prevInputTimeQuery = usePrevious(inputTimeQuery); diff --git a/packages/app/src/useQueryParam.tsx b/packages/app/src/useQueryParam.tsx deleted file mode 100644 index 15b6398af..000000000 --- a/packages/app/src/useQueryParam.tsx +++ /dev/null @@ -1,103 +0,0 @@ -import { - createContext, - useCallback, - useContext, - useEffect, - useState, -} from 'react'; -import { useRouter } from 'next/router'; - -import { usePrevious } from './utils'; - -type QueryParamContextType = Record & { - setState: (state: any) => void; -}; - -const QueryParamContext = createContext({ - setState: _ => {}, -}); - -export const QueryParamProvider = ({ - children, -}: { - children: React.ReactNode; -}) => { - const router = useRouter(); - - const prevRouterQuery = usePrevious(router.query); - - const setState = useCallback( - (state: Record) => { - // eslint-disable-next-line react-hooks/immutability - setCache(oldCache => { - const newCache = { - ...oldCache, - ...state, - }; - const { setState: _, ...newQuery } = newCache; - - router.push({ - query: newQuery, - }); - - return newCache; - }); - }, - [router], - ); - - const initState: QueryParamContextType = { - setState, - }; - - const [cache, setCache] = useState(initState); - - // Update cache if query param changes - useEffect(() => { - if (router.isReady && prevRouterQuery != router.query) { - setCache(oldCache => ({ ...oldCache, ...router.query })); - } - }, [setState, router.isReady, router.query, cache, prevRouterQuery]); - - return ( - - {children} - - ); -}; - -export function useQueryParam( - key: string, - defaultValue: T, - options: { - queryParamConfig: { - encode: ( - value: T | undefined, - ) => string | (string | null)[] | null | undefined; - decode: ( - input: string | (string | null)[] | null | undefined, - ) => T | undefined; - }; - } = { - queryParamConfig: { - encode: (value: T | undefined) => JSON.stringify(value), - decode: (input: string | (string | null)[] | null | undefined) => - Array.isArray(input) - ? input.map(i => (i != null ? JSON.parse(i) : undefined)) - : input != null - ? JSON.parse(input) - : undefined, - }, - }, -): [T, (value: T) => void] { - const qParamContext = useContext(QueryParamContext); - - const setValue = (value: T) => { - qParamContext.setState({ [key]: options.queryParamConfig.encode(value) }); - }; - - const value = - options.queryParamConfig.decode(qParamContext[key]) ?? defaultValue; - - return [value, setValue]; -} diff --git a/yarn.lock b/yarn.lock index 1824a2c1f..523d6e174 100644 --- a/yarn.lock +++ b/yarn.lock @@ -4311,7 +4311,6 @@ __metadata: "@hyperdx/browser": "npm:^0.21.1" "@hyperdx/common-utils": "npm:^0.13.0" "@hyperdx/node-opentelemetry": "npm:^0.9.0" - "@jedmao/location": "npm:^3.0.0" "@lezer/highlight": "npm:^1.2.0" "@mantine/core": "npm:^7.17.8" "@mantine/dates": "npm:^7.17.8" @@ -4385,7 +4384,6 @@ __metadata: msw: "npm:^2.3.0" msw-storybook-addon: "npm:^2.0.2" next: "npm:^16.1.5" - next-query-params: "npm:^4.3.1" next-runtime-env: "npm:1" next-seo: "npm:^4.28.1" numbro: "npm:^2.4.0" @@ -4411,7 +4409,6 @@ __metadata: recharts: "npm:^2.12.7" rrweb: "npm:2.0.0-alpha.8" sass: "npm:^1.54.8" - serialize-query-params: "npm:^2.0.2" sql-formatter: "npm:^15.4.0" sqlstring: "npm:^2.3.3" store2: "npm:^2.14.3" @@ -4425,7 +4422,6 @@ __metadata: typescript: "npm:^5.9.3" uplot: "npm:^1.6.31" uplot-react: "npm:^1.2.2" - use-query-params: "npm:^2.1.2" zod: "npm:3.25" languageName: unknown linkType: soft @@ -4935,13 +4931,6 @@ __metadata: languageName: node linkType: hard -"@jedmao/location@npm:^3.0.0": - version: 3.0.0 - resolution: "@jedmao/location@npm:3.0.0" - checksum: 10c0/dded42a7991aaf625c9696519aec188834b205b066e1696cd42280b70dff080d85f79353c3c21dd9ec8e9c122f193080c5a552d0916368dc13e1b03b11507184 - languageName: node - linkType: hard - "@jest/console@npm:30.2.0": version: 30.2.0 resolution: "@jest/console@npm:30.2.0" @@ -20340,19 +20329,6 @@ __metadata: languageName: node linkType: hard -"next-query-params@npm:^4.3.1": - version: 4.3.1 - resolution: "next-query-params@npm:4.3.1" - dependencies: - tslib: "npm:^2.0.3" - peerDependencies: - next: ^10.0.0 || ^11.0.0 || ^12.0.0 || ^13.0.0 || ^14.0.0 - react: ^16.8.0 || ^17.0.0 || ^18.0.0 - use-query-params: ^2.0.0 - checksum: 10c0/d5752b77b7d3606f36703ac664cf95a75fe0bf1c297c1f06083fe61883a6c037d633558208d4f3e03744b31a079a712ced025da7bdb6f90216eb0ec17b7509a3 - languageName: node - linkType: hard - "next-runtime-env@npm:1": version: 1.7.4 resolution: "next-runtime-env@npm:1.7.4" @@ -23842,13 +23818,6 @@ __metadata: languageName: node linkType: hard -"serialize-query-params@npm:^2.0.2": - version: 2.0.2 - resolution: "serialize-query-params@npm:2.0.2" - checksum: 10c0/fdef1e8eb45ce585b12535ab7e546a2583220a2deb969359ccef50519dc541dd6690c00c8de7a2465b9b0b0072a3308b55df77bd568284989df1e72869c9ee3f - languageName: node - linkType: hard - "serve-static@npm:^1.16.0": version: 1.16.3 resolution: "serve-static@npm:1.16.3" @@ -26471,18 +26440,6 @@ __metadata: languageName: node linkType: hard -"use-query-params@npm:^2.1.2": - version: 2.2.0 - resolution: "use-query-params@npm:2.2.0" - dependencies: - serialize-query-params: "npm:^2.0.2" - peerDependencies: - react: ">=16.8.0" - react-dom: ">=16.8.0" - checksum: 10c0/5f92511b6643e91609d1a0f043be93f0d989260a2476214c9b98b5885ff03cebe4240bbe75527a8a73c05653dae2c8a5769f72ba5ae0eacc2b0001d4732cc6b3 - languageName: node - linkType: hard - "use-sidecar@npm:^1.1.3": version: 1.1.3 resolution: "use-sidecar@npm:1.1.3" From bf84785724f8c53af1d6a848722e4a80a60ac149 Mon Sep 17 00:00:00 2001 From: Tom Alexander Date: Mon, 23 Feb 2026 16:34:30 -0500 Subject: [PATCH 3/4] null > undefined --- packages/app/src/NamespaceDetailsSidePanel.tsx | 2 +- packages/app/src/NodeDetailsSidePanel.tsx | 2 +- packages/app/src/PodDetailsSidePanel.tsx | 2 +- packages/app/src/SessionsPage.tsx | 6 +++--- 4 files changed, 6 insertions(+), 6 deletions(-) diff --git a/packages/app/src/NamespaceDetailsSidePanel.tsx b/packages/app/src/NamespaceDetailsSidePanel.tsx index d75ed2c45..44ae0bec6 100644 --- a/packages/app/src/NamespaceDetailsSidePanel.tsx +++ b/packages/app/src/NamespaceDetailsSidePanel.tsx @@ -317,7 +317,7 @@ export default function NamespaceDetailsSidePanel({ ]); const handleClose = React.useCallback(() => { - setNamespaceName(undefined); + setNamespaceName(null); }, [setNamespaceName]); if (!namespaceName) { diff --git a/packages/app/src/NodeDetailsSidePanel.tsx b/packages/app/src/NodeDetailsSidePanel.tsx index fafbeadca..60b22a0ec 100644 --- a/packages/app/src/NodeDetailsSidePanel.tsx +++ b/packages/app/src/NodeDetailsSidePanel.tsx @@ -330,7 +330,7 @@ export default function NodeDetailsSidePanel({ ]); const handleClose = React.useCallback(() => { - setNodeName(undefined); + setNodeName(null); }, [setNodeName]); if (!nodeName) { diff --git a/packages/app/src/PodDetailsSidePanel.tsx b/packages/app/src/PodDetailsSidePanel.tsx index b3d5a7e28..d4ab36718 100644 --- a/packages/app/src/PodDetailsSidePanel.tsx +++ b/packages/app/src/PodDetailsSidePanel.tsx @@ -330,7 +330,7 @@ export default function PodDetailsSidePanel({ // If we're in a nested side panel, don't close the drawer return; } - setPodName(undefined); + setPodName(null); }, [rowId, setPodName]); if (!podName) { diff --git a/packages/app/src/SessionsPage.tsx b/packages/app/src/SessionsPage.tsx index 26055be87..cd2a5b402 100644 --- a/packages/app/src/SessionsPage.tsx +++ b/packages/app/src/SessionsPage.tsx @@ -353,9 +353,9 @@ export default function SessionsPage() { (session: Session | undefined) => { if (session == null) { setSelectedSessionQuery({ - sid: undefined, - sfrom: undefined, - sto: undefined, + sid: null, + sfrom: null, + sto: null, }); } else { setSelectedSessionQuery({ From 14bb98ad3db849b5a51c0802d1c6e71bae1db5ea Mon Sep 17 00:00:00 2001 From: Tom Alexander Date: Mon, 23 Feb 2026 16:45:58 -0500 Subject: [PATCH 4/4] linting fixes --- packages/app/src/BenchmarkPage.tsx | 8 ++++---- packages/app/src/DBChartPage.tsx | 2 +- packages/app/src/DBSearchPage.tsx | 2 +- packages/app/src/components/AppNav/AppNav.tsx | 7 ++++--- packages/app/src/components/DBTracePanel.tsx | 4 +++- packages/app/src/dashboard.ts | 2 +- packages/app/src/hooks/useDashboardFilters.tsx | 2 +- packages/app/src/timeQuery.ts | 3 +-- 8 files changed, 16 insertions(+), 14 deletions(-) diff --git a/packages/app/src/BenchmarkPage.tsx b/packages/app/src/BenchmarkPage.tsx index 6de37eb79..4320bcb24 100644 --- a/packages/app/src/BenchmarkPage.tsx +++ b/packages/app/src/BenchmarkPage.tsx @@ -147,13 +147,13 @@ function useIndexes( } function BenchmarkPage() { - const [queries, setQueries] = useQueryState( + const [queries, setQueries] = useQueryState( 'queries', - parseAsJson(), + parseAsJson(v => v as string[]), ); - const [connections, setConnections] = useQueryState( + const [connections, setConnections] = useQueryState( 'connections', - parseAsJson(), + parseAsJson(v => v as string[]), ); const [iterations, setIterations] = useQueryState( 'iterations', diff --git a/packages/app/src/DBChartPage.tsx b/packages/app/src/DBChartPage.tsx index bf918f376..3709bbe0c 100644 --- a/packages/app/src/DBChartPage.tsx +++ b/packages/app/src/DBChartPage.tsx @@ -223,7 +223,7 @@ function DBChartExplorerPage() { const [chartConfig, setChartConfig] = useQueryState( 'config', - parseAsJson().withDefault({ + parseAsJson(v => v as SavedChartConfig).withDefault({ ...DEFAULT_CHART_CONFIG, source: sources?.[0]?.id ?? '', }), diff --git a/packages/app/src/DBSearchPage.tsx b/packages/app/src/DBSearchPage.tsx index 1f07d5622..f1894c019 100644 --- a/packages/app/src/DBSearchPage.tsx +++ b/packages/app/src/DBSearchPage.tsx @@ -801,7 +801,7 @@ const queryStateMap = { where: parseAsStringWithNewLines, select: parseAsStringWithNewLines, whereLanguage: parseAsStringEnum<'sql' | 'lucene'>(['sql', 'lucene']), - filters: parseAsJson(), + filters: parseAsJson(v => v as Filter[]), orderBy: parseAsStringWithNewLines, }; diff --git a/packages/app/src/components/AppNav/AppNav.tsx b/packages/app/src/components/AppNav/AppNav.tsx index ba207d131..368202a39 100644 --- a/packages/app/src/components/AppNav/AppNav.tsx +++ b/packages/app/src/components/AppNav/AppNav.tsx @@ -434,9 +434,10 @@ export default function AppNav({ fixed = false }: { fixed?: boolean }) { from: parseAsInteger.withDefault(-1), to: parseAsInteger.withDefault(-1), }); - const [inputTimeQuery] = useQueryState('tq', parseAsString.withDefault(''), { - history: 'push', - }); + const [inputTimeQuery] = useQueryState( + 'tq', + parseAsString.withDefault('').withOptions({ history: 'push' }), + ); const { data: meData } = api.useMe(); diff --git a/packages/app/src/components/DBTracePanel.tsx b/packages/app/src/components/DBTracePanel.tsx index 88b5d1bd2..d1d7503f0 100644 --- a/packages/app/src/components/DBTracePanel.tsx +++ b/packages/app/src/components/DBTracePanel.tsx @@ -96,7 +96,9 @@ export default function DBTracePanel({ const [eventRowWhere, setEventRowWhere] = useQueryState( 'eventRowWhere', - parseAsJson<{ id: string; type: string; aliasWith: WithClause[] }>(), + parseAsJson<{ id: string; type: string; aliasWith: WithClause[] }>( + v => v as { id: string; type: string; aliasWith: WithClause[] }, + ), ); const { diff --git a/packages/app/src/dashboard.ts b/packages/app/src/dashboard.ts index 33070157d..82af5f018 100644 --- a/packages/app/src/dashboard.ts +++ b/packages/app/src/dashboard.ts @@ -96,7 +96,7 @@ export function useDashboard({ const [localDashboard, setLocalDashboard] = useQueryState( 'dashboard', - parseAsJson(), + parseAsJson(v => v as Dashboard), ); const updateDashboard = useUpdateDashboard(); diff --git a/packages/app/src/hooks/useDashboardFilters.tsx b/packages/app/src/hooks/useDashboardFilters.tsx index b14212d3b..f524cd75e 100644 --- a/packages/app/src/hooks/useDashboardFilters.tsx +++ b/packages/app/src/hooks/useDashboardFilters.tsx @@ -7,7 +7,7 @@ import { FilterState, filtersToQuery, parseQuery } from '@/searchFilters'; const useDashboardFilters = (filters: DashboardFilter[]) => { const [filterQueries, setFilterQueries] = useQueryState( 'filters', - parseAsJson(), + parseAsJson(v => v as Filter[]), ); const setFilterValue = useCallback( diff --git a/packages/app/src/timeQuery.ts b/packages/app/src/timeQuery.ts index 1e3e39a12..426ee7cc5 100644 --- a/packages/app/src/timeQuery.ts +++ b/packages/app/src/timeQuery.ts @@ -110,8 +110,7 @@ export function useTimeQuery({ // Allow browser back/fwd button to modify the displayed time input value const [inputTimeQuery, setInputTimeQuery] = useQueryState( 'tq', - parseAsString.withDefault(''), - { history: 'push' }, + parseAsString.withDefault('').withOptions({ history: 'push' }), ); const prevInputTimeQuery = usePrevious(inputTimeQuery);