algolia / instantsearch

⚑️ Libraries for building performant and instant search and recommend experiences with Algolia. Compatible with JavaScript, TypeScript, React and Vue.
https://www.algolia.com/doc/guides/building-search-ui/what-is-instantsearch/js/
MIT License
3.73k stars 525 forks source link

infiniteHits cache gets cleared upon navigation [Next.js] #6353

Open enmanuelramirez-ad opened 2 months ago

enmanuelramirez-ad commented 2 months ago

πŸ› Current behavior

Issue Summary

We are experiencing an issue with our application using the useInfiniteHits hook from Algolia's React InstantSearch library. When a user navigates from a Product Listing Page (PLP) to a Product Detail Page (PDP) and then uses the browser's back button to return to the PLP, the search state resets, and all product hits are cleared (the user has to click on "Load more" again).


What We Have Tried

Algolia's In-Memory Cache

Custom Cache Implementation


Relevant Code Components

The following code has been simplified to keep only relevant parts of the current implementation and to provide a general overview.

InstantSearchWrapper Component

This component initializes the search client, sets up the initial UI state, and configures Algolia for search operations.

import { Configure, InstantSearch } from 'react-instantsearch-core';

function InstantSearchWrapper({ children, client, indexName, configuration, searchTerm }) {
  const { refinementList, hierarchicalMenu, hitsPerPage } = configuration;
  const initialUiState = { 
    [indexName]: { 
      hitsPerPage, 
      refinementList, 
      hierarchicalMenu, 
      query: searchTerm 
    } 
  };

  return (
    <InstantSearch searchClient={client} indexName={indexName} initialUiState={initialUiState}>
      <Configure clickAnalytics />
      {children}
    </InstantSearch>
  );
}

SearchQueryRenderer Component

This component dynamically generates configurations and manages the search query state.

import React, { useEffect, useState } from 'react';
import { useRouter } from 'next/router';
import { InstantSearchWrapper, SearchQuery } from './path/to/components';
import { getQueryParams } from '@/utils/Router/routerUtils';
import { buildConfiguration } from './SearchQueryRendererUtils';

export function SearchQueryRenderer({
  resultsRenderer,
  queryConfiguration,
  fallbackQueryConfiguration,
  noResultsRenderer,
}) {
  const router = useRouter();
  const { client, indexName } = useSearch(); // Custom hook to get search client and index name
  const queryParams = getQueryParams(router);
  const searchTerm = queryParams?.get('search');

  const [searchQueryState, setSearchQueryState] = useState({
    loading: true,
    hasResults: false,
    isUnsuccessfulSearch: false,
  });

  const initialConfiguration = buildConfiguration(queryConfiguration, queryParams, facetAttributes);
  const fallBackConfiguration = fallbackQueryConfiguration
    ? buildConfiguration(fallbackQueryConfiguration, queryParams, facetAttributes)
    : undefined;

  useEffect(() => {
    setSearchQueryState({
      ...searchQueryState,
      loading: true,
      hasResults: false,
    });
  }, [searchTerm]);

  return (
    <>
      {(searchQueryState.loading || searchQueryState.hasResults || !searchQueryState.isUnsuccessfulSearch) && (
        <InstantSearchWrapper
          client={client}
          indexName={indexName}
          configuration={initialConfiguration}
          searchTerm={searchTerm || ''}
        >
          <SearchQuery
            onStateChange={setSearchQueryState}
            searchTerm={searchTerm || ''}
            facets={facetAttributes}
            initialSearchProps={initialConfiguration}
          >
            {(props) => (
              <ComponentsRenderer {...props} components={[resultsRenderer]} />
            )}
          </SearchQuery>
        </InstantSearchWrapper>
      )}
    </>
  );
}

SearchQuery Component

This component manages the search query state and utilizes useInfiniteHits to fetch product hits.

import { useInfiniteHits, useInstantSearch, useSearchBox } from 'react-instantsearch-core';

const customCache = {
  read({ state }) {
    const cached = sessionStorage.getItem('infiniteHitsCache');
    if (cached) {
      const { cachedKey, cachedHits } = JSON.parse(cached);
      const currentKey = convertStateToKey(state);
      if (deepEqual(cachedKey, currentKey)) { // deep equal object comparison
        return cachedHits;
      }
    }
    return null;
  },
  write({ state, hits }) {
    const currentKey = convertStateToKey(state);
    sessionStorage.setItem('infiniteHitsCache', JSON.stringify({ cachedKey: currentKey, cachedHits: hits }));
  },
};

function SearchQuery({ onStateChange, searchTerm, facets, sortBy, initialSearchProps }) {
  const { indexUiState, setIndexUiState } = useInstantSearch();
  const { hits, showMore, isLastPage } = useInfiniteHits({ cache: customCache });

  useEffect(() => {
    if (facets && sortBy) {
      setIndexUiState({
        ...indexUiState,
        query: searchTerm,
        configure: { ...indexUiState.configure, query: searchTerm },
      });
    }
  }, [facets, sortBy, searchTerm]);

  return children({ hits, showMore, isLastPage });
}

Additional Information

Environment

Any insights or recommended approaches would be greatly appreciated.

πŸ” Steps to reproduce

Steps to Reproduce:

  1. User lands on the PLP and performs a search.
  2. User clicks on a product to navigate to the PDP.
  3. User clicks the browser back button to return to the PLP.
  4. Upon returning to the PLP, the search state and product hits are reset.

Live reproduction

none

πŸ’­ Expected behavior

Expected Behavior:

The search state and product hits should be persisted when navigating back from the PDP to the PLP, ensuring a seamless user experience without re-fetching data.

Package version

react-instantsearch-core: ^7.1.0, algoliasearch: ^4.20.0, instantsearch.js: ^4.63.0

Operating system

macOS 14.4.1

Browser

Google Chrome 128.0.6613.113

Code of Conduct

timhonders commented 1 month ago

@enmanuelramirez-ad We have the same problem, it seems on the first render with data from a server component disjunctiveFacets & disjunctiveFacetsRefinements are missing in the state.

So the compare fails and the cache is resetten on back, the same is happening on dirst render.

So we have 2 workarounds, in the cache read function.

This is not the best piece of code but it works for us for now :)

 import { isEqual } from 'instantsearch.js/es/lib/utils/isEqual';

  const getStateWithoutPage = (state) => {
      const { page, ...rest } = state || {};
      return rest;
  }

  const isServer = typeof window === 'undefined';

  // Work around for Nextjs, disjunctiveFacets & disjunctiveFacetsRefinements are missing from state, this triggers a emty hits array
  const infiniteHitsCache = (() => {

      let cache = null;

      return {

          read: ({ state }) => {

              if (cache === null || isServer) {
                  return null;
              }

              // Fix first render reset hits
              if (cache.first) {
                  cache = {
                      ...cache,
                      first: false,
                      state: {
                          ...cache.state,
                          disjunctiveFacets: state.disjunctiveFacets,
                          disjunctiveFacetsRefinements: state.disjunctiveFacetsRefinements
                      }
                  }
              }

              // Fix on back reset hits
              if (state.disjunctiveFacets.length === 0) {
                  state = {
                      ...state,
                      disjunctiveFacets: cache.state.disjunctiveFacets,
                      disjunctiveFacetsRefinements: cache.state.disjunctiveFacetsRefinements
                  }
              } 

              return cache && isEqual(getStateWithoutPage(cache.state), getStateWithoutPage(state)) ? cache.hits : null;

          },

          write: ({ state, hits }) => {

              cache = {
                  first: (state.disjunctiveFacets.length === 0),
                  state: state,
                  hits: hits
              } 

          },
      };
  })();

  export default infiniteHitsCache;