Skip to content

fix(react-instantsearch-nextjs): trigger client search on App Router SPA navigation#7019

Open
anthonybachour wants to merge 1 commit into
algolia:masterfrom
anthonybachour:fix/instantsearch-next-spa-hydration
Open

fix(react-instantsearch-nextjs): trigger client search on App Router SPA navigation#7019
anthonybachour wants to merge 1 commit into
algolia:masterfrom
anthonybachour:fix/instantsearch-next-spa-hydration

Conversation

@anthonybachour
Copy link
Copy Markdown

Summary

<InstantSearchNext> ships its SSR results to the client via an inline <script> that sets window[Symbol.for("InstantSearchInitialResults")]. The next <InstantSearchNext> mount reads that value and skips its initial network request. This works on direct navigation, but two related issues break it on App Router SPA navigation:

  1. The global is never cleared, so a value injected by an earlier render — including a Next.js <Link> prefetch of a route the user hasn't visited yet — survives across navigations and gets consumed by a different <InstantSearchNext> mount on a subsequent route. The destination renders into another mount's serialized state (different hitsPerPage, possibly no facet widgets), and the auto-search is suppressed via shouldRenderAtOnce.
  2. waitForResultsRef is provided as a stable ref object regardless of whether SSR results actually exist. useInstantSearchApi treats it as truthy in shouldRenderAtOnce = serverContext || initialResults || waitForResultsRef, so on a SPA navigation with no real initialResults it still calls search._initialResults = initialResults || {} (an empty object) and skips the initial network request. Widgets render with empty state and the SSR'd output is discarded by hydration.

In practice, after a <Link> click into a page that mounts <InstantSearchNext>, the destination's facets and hits are empty even though the SSR'd HTML on that route had the correct values; a hard reload restores them.

Fix:

  • Read-and-clear window[InstantSearchInitialResults] so a value can only be consumed once.
  • On the client, only set waitForResultsRef to promiseRef when there are real initialResults. Otherwise default to null so useInstantSearchApi falls through to its normal client-mount path and fires an initial search.

Server-side and direct-navigation paths are unchanged.

Result

Tests in packages/react-instantsearch-nextjs/src/__tests__/InstantSearchNext.test.tsx:

  • ✅ Existing: does not trigger a client-side search by default — no extra search when results are pre-populated.
  • ✅ New: fires a client-side search when no initialResults are injected — single mount with no global set fires its own search (the SPA-navigation case where the prior route never set the global).
  • ✅ New: does not reuse a previous mount's initialResults — first mount consumes (and clears) the injected SSR results; a second mount no longer recycles stale state and fires its own search.
$ jest packages/react-instantsearch-nextjs/src/__tests__/InstantSearchNext.test.tsx
PASS packages/react-instantsearch-nextjs/src/__tests__/InstantSearchNext.test.tsx
  rerendering
    ✓ does not trigger a client-side search by default (26 ms)
  SPA navigation hydration
    ✓ fires a client-side search when no initialResults are injected (5 ms)
    ✓ does not reuse a previous mount's initialResults (8 ms)

Verified end-to-end against an unrelated production project: with the patch applied to node_modules, a click from a route without <InstantSearchNext> to one with it (and the in-app useInstantSearch().refresh() workaround removed) renders the same 84 facet items / 60 hits the SSR'd HTML produced. Without the patch, the destination renders 0 facet items.

Copilot AI review requested due to automatic review settings May 9, 2026 04:11
@codacy-production
Copy link
Copy Markdown

codacy-production Bot commented May 9, 2026

Up to standards ✅

🟢 Issues 0 issues

Results:
0 new issues

View in Codacy

🟢 Metrics 0 duplication

Metric Results
Duplication 0

View in Codacy

TIP This summary will be updated as you push new changes.

Copy link
Copy Markdown
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

Fixes App Router SPA navigation hydration issues in react-instantsearch-nextjs by preventing stale SSR initial results from being reused across navigations/mounts, and by ensuring the client triggers an initial search when no SSR results were actually injected.

Changes:

  • Consume (read-and-clear) window[Symbol.for("InstantSearchInitialResults")] so injected SSR results are only used once.
  • Provide waitForResultsRef = null on client mounts when no initialResults exist, allowing useInstantSearchApi to perform its normal client-side initial search.
  • Add tests covering SPA navigation scenarios: missing injected results and preventing reuse of previous mount results.

Reviewed changes

Copilot reviewed 2 out of 2 changed files in this pull request and generated 1 comment.

File Description
packages/react-instantsearch-nextjs/src/InstantSearchNext.tsx Clears the global initial-results payload after reading it; adjusts waitForResultsRef to avoid suppressing client searches on SPA navigations without SSR payload.
packages/react-instantsearch-nextjs/src/tests/InstantSearchNext.test.tsx Adds SPA navigation hydration test cases and ensures the global initial-results payload is reset between tests.

💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.

Comment on lines +106 to +114
// Consume once: prevents an earlier mount's results (e.g. a header search box
// on a shared layout) from being reused by a later mount with different
// widgets, and from surviving an App Router SPA navigation.
const initialResults = safelyRunOnBrowser(({ window }) => {
const value = window[InstantSearchInitialResults];
if (value !== undefined) {
window[InstantSearchInitialResults] = undefined;
}
return value;
…SPA navigation

`<InstantSearchNext>` ships its SSR results to the client via an inline
<script> that sets `window[Symbol.for("InstantSearchInitialResults")]`.
That works on direct navigation: the script runs before hydration, the
component reads the global, and `useInstantSearchApi` uses
`shouldRenderAtOnce` to render with the SSR'd state.

Two related issues break this on App Router client-side navigation
(clicking a `<Link>` to a route that mounts `<InstantSearchNext>`):

1. The global is read but never cleared. When two
   `<InstantSearchNext>` instances share an `indexName` (e.g. a header
   search box on a shared layout and the page-level catalog search), the
   second mount silently consumes the first mount's serialized state —
   different `hitsPerPage`, no facet widgets, etc. — and renders into
   that, suppressing the auto-search via `shouldRenderAtOnce`.

2. `waitForResultsRef` is provided as a stable ref object regardless of
   whether SSR results actually exist. `useInstantSearchApi` treats it
   as truthy in `shouldRenderAtOnce = serverContext || initialResults || waitForResultsRef`,
   so on a SPA navigation with no real `initialResults` it still calls
   `search._initialResults = initialResults || {}` (an empty object) and
   skips the initial network request. The widgets render with empty
   state and the SSR'd output is discarded by hydration.

Fix:
- Read-and-clear `window[InstantSearchInitialResults]` so a value can
  only be consumed once. A second mount with the same indexName, or a
  later SPA navigation, no longer recycles stale state.
- On the client, only set `waitForResultsRef` to `promiseRef` when
  there are real `initialResults` to hydrate. Otherwise default to
  `null` so `useInstantSearchApi` falls through to its normal
  client-mount path and fires an initial search.

Server-side and direct-navigation paths are unchanged.

Tests: existing rerendering test still passes (no extra search when
results are pre-populated). Two new tests cover (a) firing an initial
search when the global is absent and (b) not reusing a previous mount's
results.
@anthonybachour anthonybachour force-pushed the fix/instantsearch-next-spa-hydration branch from c933148 to 48bc960 Compare May 9, 2026 16:24
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