Description
When using React InstantSearch with the Next.js App Router, navigating between two pages that each render <InstantSearch> causes the widget to render nothing (blank search box and results) after a few client-side navigations. <InstantSearch> returns null because search.started is false.
This happens with plain <InstantSearch> (not InstantSearchNext). It reproduces reliably after ~3 soft navigations and does not throw — the subtree just silently disappears.
Root cause
In useInstantSearchApi, dispose() is deferred on unmount via setTimeout(cleanup) and tracked in cleanupTimerRef, so an unmount immediately followed by a re-subscribe (a React "update") cancels the dispose:
// unmount / teardown
cleanupTimerRef.current = setTimeout(cleanup); // cleanup = () => search.dispose()
// re-subscribe
if (cleanupTimerRef.current === null) {
if (!search.started) { search.start(); forceUpdate(); }
} else {
// "just an update" — cancel the pending dispose, assume still started
clearTimeout(cleanupTimerRef.current);
search._preventWidgetCleanup = false;
}
This assumes the gap between teardown and re-subscribe is a single tick. The Next.js App Router preserves the page component fiber across navigation, so searchRef/cleanupTimerRef survive, but the user spends seconds on another route. By the time they navigate back and React re-subscribes:
- the deferred
setTimeout(cleanup) has already fired → the reused instance is disposed (started === false), and
cleanupTimerRef.current is still the (already-fired) timer id, i.e. non-null.
So the re-subscribe takes the else branch, calls clearTimeout on a dead timer, and never restarts the disposed instance. <InstantSearch> then renders null forever.
Steps to reproduce
- Next.js App Router (tested Next 16, React 19,
react-instantsearch 7.33.0).
- Two routes, each rendering its own
<InstantSearch> (different indexName).
- Soft-navigate A → B → A → B a few times (via
<Link>).
- After ~3 navigations the search box + results vanish;
search.started is false.
Proposed fix
Reset cleanupTimerRef and restart the search if the reused instance was already disposed:
} else {
clearTimeout(cleanupTimerRef.current);
cleanupTimerRef.current = null;
search._preventWidgetCleanup = false;
// The deferred dispose may have already fired during a long navigation
// round-trip (e.g. App Router preserving the page fiber), leaving the
// reused instance disposed. Restart it so InstantSearch doesn't render null.
if (!search.started) {
search.start();
forceUpdate();
}
}
Verified against the reproduction above (headless-Chrome/CDP harness): failing 100% of the time → passing 100%.
InstantSearchNext sidesteps this because its provider sets ssrSearchRef, and the teardown short-circuits (if (serverState?.ssrSearchRef) return;) so the instance is never disposed — but plain <InstantSearch> + App Router hits the bug.
Environment
- react-instantsearch / react-instantsearch-core: 7.33.0
- react / react-dom: 19
- next: 16 (App Router)
Source: packages/react-instantsearch-core/src/lib/useInstantSearchApi.tsx
Description
When using React InstantSearch with the Next.js App Router, navigating between two pages that each render
<InstantSearch>causes the widget to render nothing (blank search box and results) after a few client-side navigations.<InstantSearch>returnsnullbecausesearch.startedisfalse.This happens with plain
<InstantSearch>(notInstantSearchNext). It reproduces reliably after ~3 soft navigations and does not throw — the subtree just silently disappears.Root cause
In
useInstantSearchApi,dispose()is deferred on unmount viasetTimeout(cleanup)and tracked incleanupTimerRef, so an unmount immediately followed by a re-subscribe (a React "update") cancels the dispose:This assumes the gap between teardown and re-subscribe is a single tick. The Next.js App Router preserves the page component fiber across navigation, so
searchRef/cleanupTimerRefsurvive, but the user spends seconds on another route. By the time they navigate back and React re-subscribes:setTimeout(cleanup)has already fired → the reused instance is disposed (started === false), andcleanupTimerRef.currentis still the (already-fired) timer id, i.e. non-null.So the re-subscribe takes the
elsebranch, callsclearTimeouton a dead timer, and never restarts the disposed instance.<InstantSearch>then rendersnullforever.Steps to reproduce
react-instantsearch7.33.0).<InstantSearch>(differentindexName).<Link>).search.startedisfalse.Proposed fix
Reset
cleanupTimerRefand restart the search if the reused instance was already disposed:Verified against the reproduction above (headless-Chrome/CDP harness): failing 100% of the time → passing 100%.
InstantSearchNextsidesteps this because its provider setsssrSearchRef, and the teardown short-circuits (if (serverState?.ssrSearchRef) return;) so the instance is never disposed — but plain<InstantSearch>+ App Router hits the bug.Environment
Source:
packages/react-instantsearch-core/src/lib/useInstantSearchApi.tsx