Skip to content

Commit 5abf27e

Browse files
fix(app): open a newly saved search without reloading the page (#3565)
## Summary Saving a new search left the page through `window.location.assign`, so the whole app reloaded. On the static ClickStack export that reload is worse than slow: the export only emits `search/[savedSearchId].html` (the literal bracket filename, confirmed by running `yarn build:clickhouse`), so a document request for `/clickstack/search/<id>` has nothing to serve and the user lands on a 404 instead of their new search. The reload was added in #2669 to work around a real race, not out of preference. nuqs 1.17 batches query-string writes and, at flush time, renders them onto `next/router`'s `asPath` — which during a client-side transition is still the *old* path. Live tail rewrites `from`/`to` through nuqs every 10s with `history: 'push'`, so a flush landing mid-transition pushed `/search?...` over the in-flight navigation to `/search/<id>` and the user bounced back to an unsaved search. This removes the workaround and closes the race at both ends instead: - Live tail pauses while the save modal is open, so nothing is writing the time range to the URL while the user names their search. - The page drains the nuqs queue (`await setSearchedConfig({})`) before `router.push`, so any write already queued is applied before the transition starts rather than after it. Navigation moves to the page component, which is where the query-state setter lives; the modal reports the new id through an `onCreate` prop. `buildSavedSearchNavigationUrl` no longer takes `basePath`, since `router.push` prepends it. ### Screenshots or video Not a visual change — the diff is in how the page gets to `/search/<id>`. This is the new E2E test recorded in slow motion: the modal closes and the header switches to the saved search with no white flash and no sidebar re-mount. <a href="https://cursor.com/agents/bc-21467b1b-445a-4f9b-8fd1-828647b21dc4/artifacts?path=%2Fopt%2Fcursor%2Fartifacts%2Fsaved-search-client-side-navigation.mp4"><img src="https://cursor.com/artifacts/c/art-e0dea251-cb3c-4dbd-9ba3-05dcc4fdac9e" alt="saved-search-client-side-navigation.mp4" /></a> ### How to reproduce **Starting point:** full-stack `yarn dev` (the save flow needs the API), on `/search` with live tail on, which is the default. **On `main`:** 1. Run any search on the Search page. 2. Click **Save**, type a name, click **Save** in the modal. 3. The whole app reloads — white flash, nav re-mounts, every query re-runs. On a ClickStack deployment served from the static export, the same click lands on a 404 page instead. **On this branch:** 1. Same three steps. 2. The modal closes and the header switches to the saved search in place; nothing outside the page content re-renders. ### How to test on Vercel preview N/A — the preview runs in local mode, where saved searches live in localStorage and the reload is survivable; the failure this fixes needs the static ClickStack export or a full-stack deploy. ### Tests - New unit test (`DBSearchPage.saveSearch.test.tsx`): saving pushes `/search/<id>` through the router (basePath-free), and the queue drain is ordered before the push. - New E2E test: a marker set on `document.documentElement` survives the save, which it cannot across a document load. - `buildSavedSearchNavigationUrl` unit tests updated for the dropped `basePath`, plus a case for a live-tail range. - Separately, the existing `should handle navigation via browser back button` E2E test was racing the sessions page, which writes its default time range as a *pushed* history entry shortly after mount — one Back landed on the saved search or on bare `/sessions` depending on which won. It now waits for that write before going back. This is a pre-existing flake that the faster client-side save made easier to hit; the app-side wart (a junk history entry per page load) is untouched. `make ci-lint` and `make ci-unit` pass. The `saved-search` E2E file passes 40/40 over two repeat runs. ### References - Linear Issue: HDX-5916 - Related PRs: #2669 (introduced the reload this replaces) <div><a href="https://cursor.com/agents/bc-21467b1b-445a-4f9b-8fd1-828647b21dc4?cursor_ref=pr_footer&cursor_cta=open_in_web"><picture><source media="(prefers-color-scheme: dark)" srcset="https://cursor.com/assets/images/open-in-web-dark.png"><source media="(prefers-color-scheme: light)" srcset="https://cursor.com/assets/images/open-in-web-light.png"><img alt="Open in Web" width="114" height="28" src="https://cursor.com/assets/images/open-in-web-dark.png"></picture></a>&nbsp;<a href="https://cursor.com/background-agent?bcId=bc-21467b1b-445a-4f9b-8fd1-828647b21dc4&cursor_ref=pr_footer&cursor_cta=open_in_cursor"><picture><source media="(prefers-color-scheme: dark)" srcset="https://cursor.com/assets/images/open-in-cursor-dark.png"><source media="(prefers-color-scheme: light)" srcset="https://cursor.com/assets/images/open-in-cursor-light.png"><img alt="Open in Cursor" width="131" height="28" src="https://cursor.com/assets/images/open-in-cursor-dark.png"></picture></a>&nbsp;</div> Fixes HDX-5916 Co-authored-by: Cursor Agent <199161495+cursoragent@users.noreply.github.com>
1 parent 86cd38b commit 5abf27e

8 files changed

Lines changed: 403 additions & 25 deletions

File tree

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,7 @@
1+
---
2+
'@hyperdx/app': patch
3+
---
4+
5+
Saving a search no longer reloads the page: the new saved search is opened with
6+
a client-side navigation, which also fixes the 404 this caused on the static
7+
ClickStack build.

‎packages/app/AGENTS.md‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
<!-- BEGIN:nextjs-agent-rules -->
22

3-
# This is NOT the Next.js you know
3+
## This is NOT the Next.js you know
44

55
This version has breaking changes — APIs, conventions, and file structure may all differ from your training data. Read the relevant guide in `node_modules/next/dist/docs/` (resolved from this file's directory; in monorepos the `next` package may not be visible from the repo root) before writing any code. Heed deprecation notices.
66

‎packages/app/src/DBSearchPage.tsx‎

Lines changed: 38 additions & 17 deletions
Original file line numberDiff line numberDiff line change
@@ -465,12 +465,14 @@ function SaveSearchModalComponent({
465465
searchedConfig,
466466
opened,
467467
onClose,
468+
onCreate,
468469
isUpdate,
469470
savedSearchId,
470471
}: {
471472
searchedConfig: SearchConfig;
472473
opened: boolean;
473474
onClose: () => void;
475+
onCreate: (savedSearchId: string) => Promise<void>;
474476
isUpdate: boolean;
475477
savedSearchId: string | undefined | null;
476478
}) {
@@ -576,18 +578,8 @@ function SaveSearchModalComponent({
576578
tags: tags,
577579
});
578580

579-
// useQueryStates can restore the previous search URL during a
580-
// client-side transition. Reload the saved-search route instead so
581-
// the newly created search hydrates from its stored configuration,
582-
// rather than stale query state from the previous search. Preserve
583-
// only the independent time range, which is not saved-search config.
584-
window.location.assign(
585-
buildSavedSearchNavigationUrl(
586-
router.basePath,
587-
savedSearch.id,
588-
window.location.search,
589-
),
590-
);
581+
await onCreate(savedSearch.id);
582+
onClose();
591583
} catch (error) {
592584
console.error('Error creating saved search:', error);
593585
notifications.show({
@@ -926,8 +918,14 @@ const queryStateMap = {
926918
orderBy: parseAsStringEncoded,
927919
};
928920

921+
/**
922+
* Route for a newly created saved search. The saved-search config params are
923+
* dropped so the page hydrates from the stored configuration; only the time
924+
* range is carried over, since it is not part of a saved search.
925+
*
926+
* Returned without `router.basePath` — `router.push` prepends it.
927+
*/
929928
export function buildSavedSearchNavigationUrl(
930-
basePath: string,
931929
savedSearchId: string,
932930
currentSearch: string,
933931
) {
@@ -940,15 +938,15 @@ export function buildSavedSearchNavigationUrl(
940938
timeRangeParams.set('from', from);
941939
timeRangeParams.set('to', to);
942940

943-
// An explicit absolute range must remain in range mode after the saved
944-
// search reload; otherwise the default live-tail mode can take over.
941+
// An explicit absolute range must stay in range mode on the saved search;
942+
// otherwise the default live-tail mode can take over.
945943
if (currentParams.get('isLive') === 'false') {
946944
timeRangeParams.set('isLive', 'false');
947945
}
948946
}
949947

950948
const timeRangeSearch = timeRangeParams.toString();
951-
return `${basePath}/search/${savedSearchId}${
949+
return `/search/${savedSearchId}${
952950
timeRangeSearch ? `?${timeRangeSearch}` : ''
953951
}`;
954952
}
@@ -1769,7 +1767,14 @@ function DBSearchPageContent() {
17691767
interval,
17701768
refreshFrequency,
17711769
onTimeRangeSelect,
1772-
pause: isAnyQueryFetching || !queryReady || !isTabVisible,
1770+
// The save modal captures the search as it stands, so refreshing the time
1771+
// range underneath it is both surprising and a source of query-string
1772+
// writes that would race the navigation to the new saved search.
1773+
pause:
1774+
isAnyQueryFetching ||
1775+
!queryReady ||
1776+
!isTabVisible ||
1777+
saveSearchModalState != null,
17731778
});
17741779

17751780
// This ensures we only render this conditionally on the client
@@ -2246,6 +2251,21 @@ function DBSearchPageContent() {
22462251
[setSaveSearchModalState],
22472252
);
22482253

2254+
const onSavedSearchCreated = useCallback(
2255+
async (newSavedSearchId: string) => {
2256+
// nuqs batches query-string writes and flushes them onto whatever path
2257+
// the router reports at flush time, which during a client-side
2258+
// transition is still the old one. Draining the queue first keeps a
2259+
// pending write (live tail refreshes the time range on a timer) from
2260+
// landing after the push and sending us back to /search.
2261+
await setSearchedConfig({});
2262+
router.push(
2263+
buildSavedSearchNavigationUrl(newSavedSearchId, window.location.search),
2264+
);
2265+
},
2266+
[setSearchedConfig],
2267+
);
2268+
22492269
const onModelFormExpandClose = useCallback(() => {
22502270
setModelFormExpanded(false);
22512271
}, [setModelFormExpanded]);
@@ -2649,6 +2669,7 @@ function DBSearchPageContent() {
26492669
<SaveSearchModal
26502670
opened={saveSearchModalState != null}
26512671
onClose={clearSaveSearchModalState}
2672+
onCreate={onSavedSearchCreated}
26522673
searchedConfig={searchedConfig}
26532674
isUpdate={saveSearchModalState === 'update'}
26542675
savedSearchId={savedSearchId}

0 commit comments

Comments
 (0)