From 79ddba9ed0a7ff452e5a31d9bca65f980f68e4b6 Mon Sep 17 00:00:00 2001 From: Saksham Goyal Date: Fri, 17 Jul 2026 04:26:17 +0530 Subject: [PATCH 1/5] Fix saved search navigation race --- packages/app/src/DBSearchPage.tsx | 7 ++++++- .../app/tests/e2e/components/SavedSearchModalComponent.ts | 4 ++++ 2 files changed, 10 insertions(+), 1 deletion(-) diff --git a/packages/app/src/DBSearchPage.tsx b/packages/app/src/DBSearchPage.tsx index 42c2715d32..4efaac8f32 100644 --- a/packages/app/src/DBSearchPage.tsx +++ b/packages/app/src/DBSearchPage.tsx @@ -543,7 +543,12 @@ function SaveSearchModalComponent({ tags: tags, }); - router.push(`/search/${savedSearch.id}${window.location.search}`); + // useQueryStates can restore the previous search URL during a + // client-side transition. Reload the saved-search route instead so + // the newly created search is not replaced by the stale URL state. + window.location.assign( + `${router.basePath}/search/${savedSearch.id}${window.location.search}`, + ); onClose(); } catch (error) { console.error('Error creating saved search:', error); diff --git a/packages/app/tests/e2e/components/SavedSearchModalComponent.ts b/packages/app/tests/e2e/components/SavedSearchModalComponent.ts index c6fe196421..b5b8165c87 100644 --- a/packages/app/tests/e2e/components/SavedSearchModalComponent.ts +++ b/packages/app/tests/e2e/components/SavedSearchModalComponent.ts @@ -93,6 +93,10 @@ export class SavedSearchModalComponent { // Start waiting for URL change BEFORE clicking submit to avoid race condition const urlPromise = this.page.waitForURL(/\/search\/[a-f0-9]+/, { timeout: 15000, + // Saving now uses a document navigation to avoid useQueryStates restoring + // the stale search URL. The URL is the behavior under test, so do not + // wait for unrelated resources to finish loading. + waitUntil: 'domcontentloaded', }); await this.submit(); From 0ae7c2efaf4c0bed2631036d4b07030569257128 Mon Sep 17 00:00:00 2001 From: Saksham Goyal Date: Fri, 17 Jul 2026 11:23:21 +0530 Subject: [PATCH 2/5] Fix saved search route hydration --- .../fix-saved-search-navigation-race.md | 6 ++++++ packages/app/src/DBSearchPage.tsx | 8 +++---- .../components/SavedSearchModalComponent.ts | 21 ++++++++++--------- 3 files changed, 20 insertions(+), 15 deletions(-) create mode 100644 .changeset/fix-saved-search-navigation-race.md diff --git a/.changeset/fix-saved-search-navigation-race.md b/.changeset/fix-saved-search-navigation-race.md new file mode 100644 index 0000000000..165d4275a8 --- /dev/null +++ b/.changeset/fix-saved-search-navigation-race.md @@ -0,0 +1,6 @@ +--- +'@hyperdx/app': patch +--- + +Fix saved-search navigation so newly created searches reliably load their stored +configuration. diff --git a/packages/app/src/DBSearchPage.tsx b/packages/app/src/DBSearchPage.tsx index 4efaac8f32..de01796b36 100644 --- a/packages/app/src/DBSearchPage.tsx +++ b/packages/app/src/DBSearchPage.tsx @@ -545,11 +545,9 @@ function SaveSearchModalComponent({ // useQueryStates can restore the previous search URL during a // client-side transition. Reload the saved-search route instead so - // the newly created search is not replaced by the stale URL state. - window.location.assign( - `${router.basePath}/search/${savedSearch.id}${window.location.search}`, - ); - onClose(); + // the newly created search hydrates from its stored configuration, + // rather than the stale query state from the previous search. + window.location.assign(`${router.basePath}/search/${savedSearch.id}`); } catch (error) { console.error('Error creating saved search:', error); notifications.show({ diff --git a/packages/app/tests/e2e/components/SavedSearchModalComponent.ts b/packages/app/tests/e2e/components/SavedSearchModalComponent.ts index b5b8165c87..cb57a353ee 100644 --- a/packages/app/tests/e2e/components/SavedSearchModalComponent.ts +++ b/packages/app/tests/e2e/components/SavedSearchModalComponent.ts @@ -91,22 +91,23 @@ export class SavedSearchModalComponent { await expect(this.submitButton).toBeEnabled({ timeout: 5000 }); // Start waiting for URL change BEFORE clicking submit to avoid race condition - const urlPromise = this.page.waitForURL(/\/search\/[a-f0-9]+/, { - timeout: 15000, - // Saving now uses a document navigation to avoid useQueryStates restoring - // the stale search URL. The URL is the behavior under test, so do not - // wait for unrelated resources to finish loading. - waitUntil: 'domcontentloaded', - }); + const urlPromise = this.page.waitForURL( + url => + /\/search\/[a-f0-9]+/.test(url.pathname) && url.search.length === 0, + { + timeout: 15000, + // Saving now uses a document navigation to avoid useQueryStates restoring + // stale search URL. A saved-search route must have no query state so it + // hydrates its stored configuration. Do not wait for unrelated resources. + waitUntil: 'domcontentloaded', + }, + ); await this.submit(); // Wait for navigation to complete await urlPromise; - // Wait for modal to fully close - await expect(this.container).toBeHidden(); - await expect(this.savedSearchNameTitle).toBeVisible({ timeout: 5000 }); await expect(this.savedSearchNameTitle).toHaveText(name, { timeout: 5000 }); } From f2c86f11391d3700de97065d5e8f79bcd747149d Mon Sep 17 00:00:00 2001 From: Saksham Goyal Date: Fri, 17 Jul 2026 11:34:06 +0530 Subject: [PATCH 3/5] Preserve saved search time range --- packages/app/src/DBSearchPage.tsx | 18 ++++++++++++++++-- .../components/SavedSearchModalComponent.ts | 17 ++++++----------- .../e2e/features/search/saved-search.spec.ts | 11 +++++++++++ 3 files changed, 33 insertions(+), 13 deletions(-) diff --git a/packages/app/src/DBSearchPage.tsx b/packages/app/src/DBSearchPage.tsx index de01796b36..8ecdbb0034 100644 --- a/packages/app/src/DBSearchPage.tsx +++ b/packages/app/src/DBSearchPage.tsx @@ -546,8 +546,22 @@ function SaveSearchModalComponent({ // useQueryStates can restore the previous search URL during a // client-side transition. Reload the saved-search route instead so // the newly created search hydrates from its stored configuration, - // rather than the stale query state from the previous search. - window.location.assign(`${router.basePath}/search/${savedSearch.id}`); + // rather than stale query state from the previous search. Preserve + // only the independent time range, which is not saved-search config. + const currentParams = new URLSearchParams(window.location.search); + const timeRangeParams = new URLSearchParams(); + const from = currentParams.get('from'); + const to = currentParams.get('to'); + if (from != null && to != null) { + timeRangeParams.set('from', from); + timeRangeParams.set('to', to); + } + const timeRangeSearch = timeRangeParams.toString(); + window.location.assign( + `${router.basePath}/search/${savedSearch.id}${ + timeRangeSearch ? `?${timeRangeSearch}` : '' + }`, + ); } catch (error) { console.error('Error creating saved search:', error); notifications.show({ diff --git a/packages/app/tests/e2e/components/SavedSearchModalComponent.ts b/packages/app/tests/e2e/components/SavedSearchModalComponent.ts index cb57a353ee..8cc48fa087 100644 --- a/packages/app/tests/e2e/components/SavedSearchModalComponent.ts +++ b/packages/app/tests/e2e/components/SavedSearchModalComponent.ts @@ -91,17 +91,12 @@ export class SavedSearchModalComponent { await expect(this.submitButton).toBeEnabled({ timeout: 5000 }); // Start waiting for URL change BEFORE clicking submit to avoid race condition - const urlPromise = this.page.waitForURL( - url => - /\/search\/[a-f0-9]+/.test(url.pathname) && url.search.length === 0, - { - timeout: 15000, - // Saving now uses a document navigation to avoid useQueryStates restoring - // stale search URL. A saved-search route must have no query state so it - // hydrates its stored configuration. Do not wait for unrelated resources. - waitUntil: 'domcontentloaded', - }, - ); + const urlPromise = this.page.waitForURL(/\/search\/[a-f0-9]+/, { + timeout: 15000, + // Saving now uses a document navigation to avoid useQueryStates restoring + // stale search URL. Do not wait for unrelated resources to finish loading. + waitUntil: 'domcontentloaded', + }); await this.submit(); diff --git a/packages/app/tests/e2e/features/search/saved-search.spec.ts b/packages/app/tests/e2e/features/search/saved-search.spec.ts index b6f36e1bb5..085505e676 100644 --- a/packages/app/tests/e2e/features/search/saved-search.spec.ts +++ b/packages/app/tests/e2e/features/search/saved-search.spec.ts @@ -245,6 +245,17 @@ test.describe('Saved Search Functionality', () => { 'Info Logs Navigation Test', ); + const savedSearchParams = new URL(page.url()).searchParams; + expect(savedSearchParams.has('from')).toBe(true); + expect(savedSearchParams.has('to')).toBe(true); + expect(savedSearchParams.has('where')).toBe(false); + expect(savedSearchParams.has('orderBy')).toBe(false); + + await expect(searchPage.input).toHaveValue('SeverityText:info'); + await expect(searchPage.getOrderByEditor()).toContainText( + customOrderBy, + ); + // Capture the saved search URL (without query params) savedSearchUrl = page.url().split('?')[0]; }); From 8add92823c8d73d6c22fc2912493573ac6cb41ff Mon Sep 17 00:00:00 2001 From: Saksham Goyal Date: Thu, 23 Jul 2026 15:47:26 +0530 Subject: [PATCH 4/5] test(app): fix saved search range assertion --- .../app/tests/e2e/features/search/saved-search.spec.ts | 7 +++++-- 1 file changed, 5 insertions(+), 2 deletions(-) diff --git a/packages/app/tests/e2e/features/search/saved-search.spec.ts b/packages/app/tests/e2e/features/search/saved-search.spec.ts index 085505e676..5a0e825796 100644 --- a/packages/app/tests/e2e/features/search/saved-search.spec.ts +++ b/packages/app/tests/e2e/features/search/saved-search.spec.ts @@ -246,8 +246,11 @@ test.describe('Saved Search Functionality', () => { ); const savedSearchParams = new URL(page.url()).searchParams; - expect(savedSearchParams.has('from')).toBe(true); - expect(savedSearchParams.has('to')).toBe(true); + // This scenario starts at /search with the app's default relative + // range, not explicit URL bounds. Saving must not manufacture fixed + // bounds while stripping saved-search configuration from the URL. + expect(savedSearchParams.has('from')).toBe(false); + expect(savedSearchParams.has('to')).toBe(false); expect(savedSearchParams.has('where')).toBe(false); expect(savedSearchParams.has('orderBy')).toBe(false); From 14aa5f6b8d7aa19a9ed5a0c481193a3ea1e0e361 Mon Sep 17 00:00:00 2001 From: Saksham Goyal Date: Thu, 23 Jul 2026 16:16:30 +0530 Subject: [PATCH 5/5] fix(app): preserve absolute saved search ranges --- packages/app/src/DBSearchPage.tsx | 44 ++++++++++++++----- .../app/src/__tests__/DBSearchPage.test.tsx | 28 +++++++++++- 2 files changed, 59 insertions(+), 13 deletions(-) diff --git a/packages/app/src/DBSearchPage.tsx b/packages/app/src/DBSearchPage.tsx index 8ecdbb0034..a5e4d66c70 100644 --- a/packages/app/src/DBSearchPage.tsx +++ b/packages/app/src/DBSearchPage.tsx @@ -548,19 +548,12 @@ function SaveSearchModalComponent({ // the newly created search hydrates from its stored configuration, // rather than stale query state from the previous search. Preserve // only the independent time range, which is not saved-search config. - const currentParams = new URLSearchParams(window.location.search); - const timeRangeParams = new URLSearchParams(); - const from = currentParams.get('from'); - const to = currentParams.get('to'); - if (from != null && to != null) { - timeRangeParams.set('from', from); - timeRangeParams.set('to', to); - } - const timeRangeSearch = timeRangeParams.toString(); window.location.assign( - `${router.basePath}/search/${savedSearch.id}${ - timeRangeSearch ? `?${timeRangeSearch}` : '' - }`, + buildSavedSearchNavigationUrl( + router.basePath, + savedSearch.id, + window.location.search, + ), ); } catch (error) { console.error('Error creating saved search:', error); @@ -898,6 +891,33 @@ const queryStateMap = { orderBy: parseAsStringEncoded, }; +export function buildSavedSearchNavigationUrl( + basePath: string, + savedSearchId: string, + currentSearch: string, +) { + const currentParams = new URLSearchParams(currentSearch); + const timeRangeParams = new URLSearchParams(); + const from = currentParams.get('from'); + const to = currentParams.get('to'); + + if (from != null && to != null) { + timeRangeParams.set('from', from); + timeRangeParams.set('to', to); + + // An explicit absolute range must remain in range mode after the saved + // search reload; otherwise the default live-tail mode can take over. + if (currentParams.get('isLive') === 'false') { + timeRangeParams.set('isLive', 'false'); + } + } + + const timeRangeSearch = timeRangeParams.toString(); + return `${basePath}/search/${savedSearchId}${ + timeRangeSearch ? `?${timeRangeSearch}` : '' + }`; +} + export function useSearchTelemetry({ isAnyQueryFetching, isLive, diff --git a/packages/app/src/__tests__/DBSearchPage.test.tsx b/packages/app/src/__tests__/DBSearchPage.test.tsx index df8eea839b..584dde8bc9 100644 --- a/packages/app/src/__tests__/DBSearchPage.test.tsx +++ b/packages/app/src/__tests__/DBSearchPage.test.tsx @@ -1,7 +1,11 @@ import { SourceKind } from '@hyperdx/common-utils/dist/types'; import { renderHook } from '@testing-library/react'; -import { getDefaultSourceId, useDefaultOrderBy } from '@/DBSearchPage'; +import { + buildSavedSearchNavigationUrl, + getDefaultSourceId, + useDefaultOrderBy, +} from '@/DBSearchPage'; import * as metadataModule from '@/hooks/useMetadata'; import * as sourceModule from '@/source'; @@ -10,6 +14,28 @@ jest.mock('@/layout', () => ({ withAppNav: (component: any) => component, })); +describe('buildSavedSearchNavigationUrl', () => { + it('drops saved-search query state when the source uses the default relative range', () => { + expect( + buildSavedSearchNavigationUrl( + '/clickstack', + 'saved-search-id', + '?where=service%3Aapi&orderBy=timestamp', + ), + ).toBe('/clickstack/search/saved-search-id'); + }); + + it('keeps an absolute range out of live-tail mode', () => { + expect( + buildSavedSearchNavigationUrl( + '/clickstack', + 'saved-search-id', + '?from=100&to=200&isLive=false&where=service%3Aapi&orderBy=timestamp', + ), + ).toBe('/clickstack/search/saved-search-id?from=100&to=200&isLive=false'); + }); +}); + describe('useDefaultOrderBy', () => { beforeEach(() => { jest.clearAllMocks();