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 42c2715d32..a5e4d66c70 100644 --- a/packages/app/src/DBSearchPage.tsx +++ b/packages/app/src/DBSearchPage.tsx @@ -543,8 +543,18 @@ function SaveSearchModalComponent({ tags: tags, }); - router.push(`/search/${savedSearch.id}${window.location.search}`); - onClose(); + // 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 stale query state from the previous search. Preserve + // only the independent time range, which is not saved-search config. + window.location.assign( + buildSavedSearchNavigationUrl( + router.basePath, + savedSearch.id, + window.location.search, + ), + ); } catch (error) { console.error('Error creating saved search:', error); notifications.show({ @@ -881,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(); diff --git a/packages/app/tests/e2e/components/SavedSearchModalComponent.ts b/packages/app/tests/e2e/components/SavedSearchModalComponent.ts index c6fe196421..8cc48fa087 100644 --- a/packages/app/tests/e2e/components/SavedSearchModalComponent.ts +++ b/packages/app/tests/e2e/components/SavedSearchModalComponent.ts @@ -93,6 +93,9 @@ 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 + // stale search URL. Do not wait for unrelated resources to finish loading. + waitUntil: 'domcontentloaded', }); await this.submit(); @@ -100,9 +103,6 @@ export class SavedSearchModalComponent { // 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 }); } 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..5a0e825796 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,20 @@ test.describe('Saved Search Functionality', () => { 'Info Logs Navigation Test', ); + const savedSearchParams = new URL(page.url()).searchParams; + // 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); + + 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]; });