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

    Fields

    No fields configured for issues without a type.

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions