Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 6 additions & 0 deletions .changeset/fix-saved-search-navigation-race.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,6 @@
---
'@hyperdx/app': patch
---

Fix saved-search navigation so newly created searches reliably load their stored
configuration.
41 changes: 39 additions & 2 deletions packages/app/src/DBSearchPage.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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({
Expand Down Expand Up @@ -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,
Expand Down
28 changes: 27 additions & 1 deletion packages/app/src/__tests__/DBSearchPage.test.tsx
Original file line number Diff line number Diff line change
@@ -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';

Expand All @@ -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();
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -93,16 +93,16 @@ 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();

// 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 });
}
Comment on lines 93 to 108
Expand Down
14 changes: 14 additions & 0 deletions packages/app/tests/e2e/features/search/saved-search.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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);
Comment on lines +248 to +255

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];
});
Expand Down
Loading