fix(react-instantsearch-nextjs): trigger client search on App Router SPA navigation#7019
Open
anthonybachour wants to merge 1 commit into
Open
Conversation
Up to standards ✅🟢 Issues
|
| Metric | Results |
|---|---|
| Duplication | 0 |
TIP This summary will be updated as you push new changes.
Contributor
There was a problem hiding this comment.
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 = nullon client mounts when noinitialResultsexist, allowinguseInstantSearchApito 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.
c933148 to
48bc960
Compare
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Summary
<InstantSearchNext>ships its SSR results to the client via an inline<script>that setswindow[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:<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 (differenthitsPerPage, possibly no facet widgets), and the auto-search is suppressed viashouldRenderAtOnce.waitForResultsRefis provided as a stable ref object regardless of whether SSR results actually exist.useInstantSearchApitreats it as truthy inshouldRenderAtOnce = serverContext || initialResults || waitForResultsRef, so on a SPA navigation with no realinitialResultsit still callssearch._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:
window[InstantSearchInitialResults]so a value can only be consumed once.waitForResultsReftopromiseRefwhen there are realinitialResults. Otherwise default tonullsouseInstantSearchApifalls 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:does not trigger a client-side search by default— no extra search when results are pre-populated.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).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.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-appuseInstantSearch().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.