Skip to content

InstantSearch renders null after repeated App Router navigation (deferred dispose not restarted) #7110

Description

@peyronoscar

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:

  1. the deferred setTimeout(cleanup) has already fired → the reused instance is disposed (started === false), and
  2. 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

  1. Next.js App Router (tested Next 16, React 19, react-instantsearch 7.33.0).
  2. Two routes, each rendering its own <InstantSearch> (different indexName).
  3. Soft-navigate A → B → A → B a few times (via <Link>).
  4. 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

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Type

    No type

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions