Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

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

Open
1 task done
enmanuelramirez-ad opened this issue Sep 9, 2024 · 1 comment
Open
1 task done
Labels
triage Issues to be categorized by the team

Comments

@enmanuelramirez-ad
Copy link

🐛 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

  • According to the Algolia documentation, an in-memory cache is used to persist product hits and search state. However, this does not seem to persist across navigations using the browser's back button.

Custom Cache Implementation

  • We implemented a custom cache object that reads from and writes to sessionStorage to track product hits and search state. This approach partially works:
    • On the first navigation back from the PDP to the PLP, the search state and product hits are correctly persisted.
    • On subsequent navigations, the cache seems to clear, and the state resets again.

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

  • We are aware of the react-instantsearch-router-nextjs library, which handles URL-based routing for state persistence. However, integrating this is not an option for us because we have custom routing logic that would be overwritten by allowing InstantSearch to control the URL.
  • We confirmed that the Algolia client is created only once and is not being reinitialized during navigation, ruling out client recreation as the cause.

Environment

  • next: ^12.1.6
  • react: 18.0.0
  • react-dom: 18.0.0

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

  • I agree to follow this project's Code of Conduct
@enmanuelramirez-ad enmanuelramirez-ad added the triage Issues to be categorized by the team label Sep 9, 2024
@timhonders
Copy link

timhonders commented Oct 14, 2024

@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;

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment
Labels
triage Issues to be categorized by the team
Projects
None yet
Development

No branches or pull requests

2 participants