refactor(search): rewrite search page with nuqs URL state#828
refactor(search): rewrite search page with nuqs URL state#828thostetler wants to merge 3 commits intoadsabs:masterfrom
Conversation
3acbd1b to
859abec
Compare
There was a problem hiding this comment.
Pull request overview
High regression risk due to a broad refactor of search state management (Zustand → URL via nuqs) that touches core search UX, facets, and several cross-cutting components (telemetry, highlights, stats, and deep links).
Changes:
- Replaces the Zustand “search” slice with nuqs-managed URL state and introduces a smaller persisted pagination preference slice.
- Refactors the search page shell + facets/results rendering to consume URL state, adds NProgress signaling, and threads “extra Solr params” through requests.
- Adds substantial new test coverage for the new hooks and search-page integration behavior.
Reviewed changes
Copilot reviewed 56 out of 57 changed files in this pull request and generated 6 comments.
Show a summary per file
| File | Description |
|---|---|
| src/theme.ts | Minor Chakra theme adjustment (border radius). |
| src/store/types.ts | Replaces search slice types with pagination slice types in app state/actions. |
| src/store/store.ts | Swaps searchSlice for paginationSlice in store composition and persistence. |
| src/store/slices/search.ts | Removes Zustand search slice (query/latestQuery/etc). |
| src/store/slices/pagination.ts | Adds persisted numPerPage preference slice. |
| src/store/slices/index.ts | Re-exports pagination slice instead of search slice. |
| src/query-utils.ts | Moves defaultQueryParams import to API models. |
| src/providers.tsx | Wraps app in NuqsAdapter; telemetry now tags router query params. |
| src/pages/search/index.tsx | Major search page overhaul to use useSearchPage (nuqs URL state), new results list, NProgress driven by fetching, highlights toggle wiring. |
| src/pages/search/tests/search-page.integration.test.tsx | Adds integration tests for search page wiring (submit, facets, pagination, errors, highlights, docs sync). |
| src/pages/paper-form.tsx | Removes intermediate query clearing on mount (search slice removed). |
| src/pages/index.tsx | Removes submitQuery store call before navigating to search. |
| src/pages/feedback/general.tsx | Uses router query string for “current_query” instead of store latestQuery. |
| src/pages/classic-form.tsx | Removes intermediate query clearing on mount. |
| src/lib/useIntermediateQuery.ts | Removes intermediate query hook (search slice removed). |
| src/lib/search/useSearchResults.ts | New hook: wraps useSearch, adds slow-search detection, supports extra Solr params. |
| src/lib/search/useSearchResults.test.ts | Unit tests for useSearchResults. |
| src/lib/search/useSearchQueryParams.ts | New nuqs-based URL parser/setter for search params (including hl mapping). |
| src/lib/search/useSearchQueryParams.test.ts | Unit tests for nuqs URL param parsing and updates. |
| src/lib/search/useSearchPage.ts | New composition hook: URL params + results + handlers + persists page-size preference. |
| src/lib/search/useSearchPage.test.ts | Unit tests for useSearchPage handlers. |
| src/lib/search/toApiParams.ts | New helper to convert nuqs params into Solr-safe API params (strips nuqs-only fields). |
| src/lib/search/toApiParams.test.ts | Unit tests for Solr param projection and extraSolrParams behavior. |
| src/lib/search/filterBoundFq.ts | New helper to drop unbound local-param fq entries (prevents Solr 400s). |
| src/lib/search/filterBoundFq.test.ts | Unit tests for local-param fq filtering behavior. |
| src/components/Visualizations/Graphs/Histogram.tsx | Adds guard against missing data when updating bin colors. |
| src/components/Sort/Sort.tsx | Improves sort control layout + adds custom dropdown indicator. |
| src/components/Select/Select.tsx | Tweaks sort-themed control height to reduce layout shift. |
| src/components/SearchResultsList/SearchResultsSkeleton.tsx | New skeleton to reserve layout during loading. |
| src/components/SearchResultsList/SearchResultsList.tsx | New pure renderer for results list (skeleton + reduced opacity while fetching). |
| src/components/SearchResultsList/SearchResultsList.test.tsx | Unit tests for skeleton/rendering/opacity. |
| src/components/SearchResultsList/SearchResultsList.integration.test.tsx | Integration tests for citation rendering mode. |
| src/components/SearchQueryLink/SearchQueryLink.tsx | Sets normal font weight for search query links. |
| src/components/SearchFacet/YearHistogramSlider.tsx | Stops reading query from store; accepts params explicitly. |
| src/components/SearchFacet/useGetFacetData.ts | Refactors hook to accept search params explicitly (no store dependency). |
| src/components/SearchFacet/useGetFacetData.test.ts | Updates tests to pass explicit params and new defaultQueryParams import. |
| src/components/SearchFacet/SearchFacetModal/SearchFacetModal.tsx | Threads searchParams through to facet download behavior. |
| src/components/SearchFacet/SearchFacet.tsx | Stops reading latestQuery from store; passes params down and applies filters against provided params. |
| src/components/SearchFacet/FacetTree.tsx | New extracted facet-tree renderer with keyboard nav and nested expansion. |
| src/components/SearchFacet/FacetTree.test.tsx | Unit tests for checkbox label/count rendering. |
| src/components/SearchFacet/FacetPagination.tsx | New extracted “load more” button component. |
| src/components/SearchFacet/FacetList.tsx | Refactors facet list to use FacetTree + explicit searchParams; updates modal rendering. |
| src/components/SearchFacet/config.ts | Updates facet config typing to exclude new required params prop. |
| src/components/SearchBar/hooks/UseSyncWithGlobal.tsx | Reworks search input sync to follow URL q (removes intermediate store sync). |
| src/components/ResultList/useHighlights.ts | Refactors highlight fetching to accept params + boolean (no store dependency). |
| src/components/ResultList/SimpleResultList.tsx | Reads hl/params from URL to drive highlight fetching. |
| src/components/ResultList/ListActions.tsx | Drives sort/highlights toggle from props (URL state) instead of store. |
| src/components/ResultList/Item/Item.tsx | Preserves q in citations/credits/abstract links via router query. |
| src/components/NumFound/NumFound.tsx | Drives search stats from router query instead of store latestQuery. |
| src/components/Libraries/AddToLibraryModal.tsx | Uses URL q when adding “all results” by query. |
| src/components/EmailNotifications/Forms/QueryForm.tsx | Uses URL q when creating query-based notifications. |
| src/components/AbstractSideNav/AbstractSideNav.tsx | Preserves q in abstract subpage navigation links. |
| src/components/AbstractSearchForm/AbstractSearchForm.tsx | Removes store submitQuery and reads query from URL. |
| src/api/search/models.ts | Adds defaultQueryParams (migrated from removed store slice). |
| src/tests/query-utils.test.ts | Updates import for moved defaultQueryParams. |
| package.json | Adds nuqs dependency. |
| pnpm-lock.yaml | Locks nuqs and its transitive deps. |
Files not reviewed (1)
- pnpm-lock.yaml: Language not supported
Comments suppressed due to low confidence (1)
src/components/SearchBar/hooks/UseSyncWithGlobal.tsx:22
- In the routeChangeStart handler,
qis typed asstring | undefinedbut is passed directly aspayload.query(which expects astring). This will either fail TypeScript compilation or dispatchundefinedinto the reducer (which calls.length). Defaultqto an empty string (or return early) before dispatching.
const handler = (url: string) => {
if (url.startsWith('/search')) {
const { q } = parseQueryFromUrl(url) as { q: string | undefined };
dispatch({
type: 'SET_SEARCH_TERM',
payload: { query: q, cursorPosition: q ? q.length : 0 },
});
src/components/NumFound/NumFound.tsx
Outdated
| : typeof routerQuery.sort === 'string' | ||
| ? ([routerQuery.sort] as IADSApiSearchParams['sort']) | ||
| : []; | ||
| const { data, isSuccess } = useGetSearchStats({ q, sort }); |
There was a problem hiding this comment.
SortStats now builds params from router.query but only forwards q and sort. This drops active filters (fq) and any other Solr params, so citation stats can be computed for the unfiltered query and show incorrect numbers. Consider deriving the full Solr params from the URL (including fq) and stripping only nuqs-only keys before calling useGetSearchStats.
| const { data, isSuccess } = useGetSearchStats({ q, sort }); | |
| const fqParam = routerQuery.fq; | |
| const fq = | |
| Array.isArray(fqParam) | |
| ? (fqParam as IADSApiSearchParams['fq']) | |
| : typeof fqParam === 'string' | |
| ? ([fqParam] as IADSApiSearchParams['fq']) | |
| : undefined; | |
| const { data, isSuccess } = useGetSearchStats( | |
| fq ? { q, sort, fq } : { q, sort }, | |
| ); |
| platform, | ||
| os: `${osName} ${osVersion}`, | ||
| current_page: router.query.from ? (router.query.from as string) : undefined, | ||
| current_query: makeSearchParams(currentQuery), | ||
| current_query: makeSearchParams(router.query), | ||
| url: router.asPath, | ||
| comments, |
There was a problem hiding this comment.
makeSearchParams(router.query) is not type-safe (router.query may not contain required q) and can serialize unrelated keys (e.g. from) into current_query. Prefer building a SafeSearchUrlParams object explicitly (ensuring q is present) or use parseQueryFromUrl(router.asPath) and then makeSearchParams on the parsed search params.
| const sendQueryAsTags = (query: ParsedUrlQuery) => { | ||
| Object.keys(query).forEach((key) => { | ||
| const value = JSON.stringify(query[key]); | ||
| const value = query[key]; | ||
| if (Array.isArray(value)) { | ||
| Sentry.setTag(`query.${key}`, value.join(' | ')); | ||
| } else { | ||
| Sentry.setTag(`query.${key}`, value); | ||
| Sentry.setTag(`query.${key}`, JSON.stringify(value)); | ||
| } |
There was a problem hiding this comment.
For non-array query values, JSON.stringify(value) will wrap strings in quotes (e.g. "stars") and will produce undefined for missing keys, which isn’t a valid Sentry tag value. Prefer skipping undefined/null values and using String(value) for scalars to keep tags readable and consistent.
| const setExpanded = useFacetStore(selectors.setExpanded); | ||
| const setCollapsed = useFacetStore(selectors.setCollapsed); | ||
|
|
||
| const checkboxRef = useRef<HTMLInputElement>(); |
There was a problem hiding this comment.
useRef<HTMLInputElement>() creates a ref with an undefined initial value but is later treated as an element ref. Initializing with null (e.g. useRef<HTMLInputElement | null>(null)) avoids unsound typing and matches typical ref usage patterns when passing refs to Chakra/React components.
| const checkboxRef = useRef<HTMLInputElement>(); | |
| const checkboxRef = useRef<HTMLInputElement | null>(null); |
| export const searchParamsParsers = { | ||
| q: parseAsString.withDefault(''), | ||
| sort: parseAsArrayOf(parseAsString).withDefault([...APP_DEFAULTS.SORT]), | ||
| p: parseAsInteger.withDefault(1), | ||
| rows: parseAsInteger.withDefault(APP_DEFAULTS.RESULT_PER_PAGE), | ||
| fq: parseAsNativeArrayOf(parseAsString).withDefault([]), | ||
| d: parseAsString.withDefault(''), | ||
| showHighlights: parseAsBoolean.withDefault(false), | ||
| }; |
There was a problem hiding this comment.
rows defaults to APP_DEFAULTS.RESULT_PER_PAGE, but the app also persists the user’s preferred page size in the Zustand numPerPage slice. With the new URL-as-source-of-truth approach, starting a new search from the home/abstract forms (which don’t include rows in the URL) will ignore the persisted preference. Consider seeding the default rows from state.numPerPage when the URL has no explicit rows value (e.g., by building the parsers with a memoized default from the store, or by writing rows into the URL on first mount).
|
|
||
| export const FacetList = (props: IFacetListProps) => { | ||
| const { noLoadMore, onFilter, onError, label } = props; | ||
| const { searchParams, onFilter, onError, label } = props; |
There was a problem hiding this comment.
onError is typed as optional in IFacetListProps, but it’s passed through to FacetTree (and NodeListModal) where onError is required. This makes onError inferred as (() => void) | undefined inside FacetList, which is not assignable and should fail TypeScript compilation. Either make onError required in IFacetListProps or default it to a no-op before passing it down.
| const { searchParams, onFilter, onError, label } = props; | |
| const { searchParams, onFilter, onError = () => {}, label } = props; |
859abec to
f55c3ca
Compare
55d50f9 to
60eefc3
Compare
- Install nuqs for type-safe URL search params - Add useSearchQueryParams, useSearchResults, useSearchPage hooks - Extract FacetTree and FacetPagination sub-components - Replace search slice with URL-driven state; remove Zustand search slice - Add post-rewrite fixes, visual polish, and integration tests
- Move integration test out of pages/ dir to fix CI build - Restore SearchErrorAlert for rich error display with Solr details - Fix facets stuck in loading state (React Query v4 disabled query behavior) - Preserve fq/sort context in abstract links, citations, and side nav - Fix feedback form current_query parsing from originating search URL - Persist numPerPage preference across navigations via Zustand - Guard SearchBar against dispatching undefined query fix: address PR review comments and move integration test out of pages dir - Move search-page integration test out of pages/ to fix Next.js treating it as a page and failing the build - Include fq filters in SortStats citation stats query (was computing stats against the unfiltered query) - Use parseQueryFromUrl in feedback form to avoid serializing unrelated router params into current_query - Skip null/undefined values in Sentry tag serialization; use String() instead of JSON.stringify for scalars - Initialize checkboxRef with null in FacetTree (standard ref pattern) - Default onError to no-op in FacetList to satisfy required prop in FacetTree fix: address behavioral regressions from review notes - Seed rows from persisted numPerPage preference when URL has no explicit rows param (navigating from home/abstract was ignoring the user's saved page size) - Include fq and sort in AddToLibraryModal and QueryForm all-results queries (was operating on unfiltered q only, dropping active filters) - Include fq and sort in abstract/citation link queries in Item and AbstractSideNav (was dropping search context on navigation) - Guard against undefined q in UseSyncWithGlobal route handler - Fix facets stuck in loading state: useObjects isLoading/isFetching were OR-ed unconditionally; disabled queries in RQ v4 start in loading state, so non-simbad facets never left the spinner fix: address Codex review findings
60eefc3 to
1c55429
Compare
Search page state was managed by a Zustand slice that mirrored URL params, creating two sources of truth and making it difficult to share search links reliably.