Skip to content

refactor(search): rewrite search page with nuqs URL state#828

Draft
thostetler wants to merge 3 commits intoadsabs:masterfrom
thostetler:feature/search-rewrite
Draft

refactor(search): rewrite search page with nuqs URL state#828
thostetler wants to merge 3 commits intoadsabs:masterfrom
thostetler:feature/search-rewrite

Conversation

@thostetler
Copy link
Member

@thostetler thostetler commented Mar 21, 2026

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.

  • Replace Zustand search slice with nuqs for type-safe, URL-as-source-of-truth search params
  • Add useSearchQueryParams, useSearchResults, and useSearchPage composition hooks
  • Extract FacetTree and FacetPagination sub-components from the facet panel
  • Restore SearchErrorAlert for rich error display with Solr details, Copy, and Try Again
  • Fix facets stuck in loading state caused by React Query v4 disabled-query behavior
  • Preserve fq/sort context in abstract links, citation links, and side nav
  • Persist numPerPage preference across navigations via Zustand
  • Fix feedback form capturing the originating search URL instead of the feedback page URL
  • Move integration test out of pages/ dir to fix CI build

@codecov
Copy link

codecov bot commented Mar 21, 2026

Codecov Report

❌ Patch coverage is 68.07474% with 393 lines in your changes missing coverage. Please review.
✅ Project coverage is 62.4%. Comparing base (bb93ce0) to head (ba7664c).

Files with missing lines Patch % Lines
src/components/SearchFacet/FacetTree.tsx 29.5% 261 Missing and 2 partials ⚠️
src/pages/search/index.tsx 75.5% 55 Missing ⚠️
src/components/SearchFacet/FacetPagination.tsx 34.7% 34 Missing ⚠️
.../components/EmailNotifications/Forms/QueryForm.tsx 13.4% 13 Missing ⚠️
src/components/ResultList/Item/Item.tsx 68.5% 1 Missing and 5 partials ⚠️
src/components/Visualizations/Graphs/Histogram.tsx 0.0% 6 Missing ⚠️
src/components/SearchFacet/useGetFacetData.ts 68.8% 3 Missing and 2 partials ⚠️
src/components/SearchFacet/YearHistogramSlider.tsx 33.4% 4 Missing ⚠️
src/components/AbstractSideNav/AbstractSideNav.tsx 87.5% 2 Missing ⚠️
...c/components/SearchBar/hooks/UseSyncWithGlobal.tsx 80.0% 2 Missing ⚠️
... and 3 more
Additional details and impacted files
@@           Coverage Diff            @@
##           master    #828     +/-   ##
========================================
- Coverage    62.4%   62.4%   -0.0%     
========================================
  Files         317     337     +20     
  Lines       36565   38685   +2120     
  Branches     1670    1764     +94     
========================================
+ Hits        22816   24119   +1303     
- Misses      13711   14510    +799     
- Partials       38      56     +18     
Files with missing lines Coverage Δ
src/api/search/models.ts 52.9% <100.0%> (+7.1%) ⬆️
src/components/ResultList/ListActions.tsx 78.3% <100.0%> (+0.3%) ⬆️
src/components/SearchFacet/config.ts 100.0% <100.0%> (ø)
src/components/SearchQueryLink/SearchQueryLink.tsx 78.3% <100.0%> (+4.0%) ⬆️
...components/SearchResultsList/SearchResultsList.tsx 100.0% <100.0%> (ø)
...onents/SearchResultsList/SearchResultsSkeleton.tsx 100.0% <100.0%> (ø)
src/components/Select/Select.tsx 77.6% <100.0%> (ø)
src/components/Sort/Sort.tsx 81.2% <100.0%> (+0.7%) ⬆️
src/lib/search/filterBoundFq.ts 100.0% <100.0%> (ø)
src/lib/search/toApiParams.ts 100.0% <100.0%> (ø)
... and 20 more

... and 17 files with indirect coverage changes

🚀 New features to boost your workflow:
  • 📦 JS Bundle Analysis: Save yourself from yourself by tracking and limiting bundle sizes in JS merges.

Copy link
Contributor

Copilot AI left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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, q is typed as string | undefined but is passed directly as payload.query (which expects a string). This will either fail TypeScript compilation or dispatch undefined into the reducer (which calls .length). Default q to 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 },
        });

: typeof routerQuery.sort === 'string'
? ([routerQuery.sort] as IADSApiSearchParams['sort'])
: [];
const { data, isSuccess } = useGetSearchStats({ q, sort });
Copy link

Copilot AI Mar 21, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

Suggested change
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 },
);

Copilot uses AI. Check for mistakes.
Comment on lines 128 to 133
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,
Copy link

Copilot AI Mar 21, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

Copilot uses AI. Check for mistakes.
Comment on lines +104 to 111
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));
}
Copy link

Copilot AI Mar 21, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

Copilot uses AI. Check for mistakes.
const setExpanded = useFacetStore(selectors.setExpanded);
const setCollapsed = useFacetStore(selectors.setCollapsed);

const checkboxRef = useRef<HTMLInputElement>();
Copy link

Copilot AI Mar 21, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

Suggested change
const checkboxRef = useRef<HTMLInputElement>();
const checkboxRef = useRef<HTMLInputElement | null>(null);

Copilot uses AI. Check for mistakes.
Comment on lines +19 to +27
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),
};
Copy link

Copilot AI Mar 21, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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).

Copilot uses AI. Check for mistakes.

export const FacetList = (props: IFacetListProps) => {
const { noLoadMore, onFilter, onError, label } = props;
const { searchParams, onFilter, onError, label } = props;
Copy link

Copilot AI Mar 21, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

Suggested change
const { searchParams, onFilter, onError, label } = props;
const { searchParams, onFilter, onError = () => {}, label } = props;

Copilot uses AI. Check for mistakes.
@thostetler thostetler force-pushed the feature/search-rewrite branch from 859abec to f55c3ca Compare March 21, 2026 00:54
@thostetler thostetler marked this pull request as draft March 21, 2026 01:12
@thostetler thostetler force-pushed the feature/search-rewrite branch from 55d50f9 to 60eefc3 Compare March 21, 2026 01:27
@thostetler thostetler changed the title Feature/search Refactor Pass #1 refactor(search): rewrite search page with nuqs URL state Mar 21, 2026
- 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
@thostetler thostetler force-pushed the feature/search-rewrite branch from 60eefc3 to 1c55429 Compare March 21, 2026 01:39
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants