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.67k stars 514 forks source link

Cache for useInfiniteHits is not working correctly in certain situations #5239

Open Sartonon opened 2 years ago

Sartonon commented 2 years ago

🐛 Bug description

useInfiniteHits cache functionality breaks when hooks are used in a certain way for custom search UI components. With components from instantsearch library everything works fine.

Cache read callback is called multiple times and initially state doesn't include all the facets when coming back to search page and it doesn't match with what is in cache before write is called and cache resets.

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

https://github.com/algolia/instantsearch.js/blob/master/packages/instantsearch.js/src/lib/infiniteHitsCache/sessionStorage.ts#L29

Example includes custom cache where it is easy to see that read function is called multiple times and state is not up to date until later and write gets called before state gets matched from session storage:

const customCache = {
  write: ({ hits, state }) => {
    console.log("WRITE");
    sessionStorage.setItem(
      KEY,
      JSON.stringify({
        state: getStateWithoutPage(state),
        hits: hits
      })
    );
  },
  read: ({ state }) => {
    console.log(state);
    var cache = JSON.parse(sessionStorage.getItem(KEY));
    return cache && dequal(state, getStateWithoutPage(state))
      ? cache.hits
      : null;
  }
};

Console logs when coming back from product page

Screenshot 2022-09-12 at 12 45 12

In the logs you can see that disjunctiveFacets is empty array at first and it won't match with the one in cache. writegets called and cache basically resets.

<InstantSearchFilters /> can be uncommented to check working functionality

 <div>
        <CustomInfiniteHits />
        {/* <InfiniteHits hitComponent={Hit} cache={cache} /> */}
      </div>
      <div>
        {/* Cache resets when custom components with hooks is used */}
        <CustomFilters attributesToRender={attributesToRender} />
        {/* Cache works correctly with ready made UI components */}
        {/* <InstantSearchFilters /> */}
 </div>

🔍 Bug reproduction

Steps to reproduce the behavior:

  1. Go to https://codesandbox.io/s/musing-cori-g9895q
  2. Click "Show more"
  3. Click product to go to product page
  4. Go back to search page
  5. Results should have 40 items but has only 20.

Live reproduction:

https://codesandbox.io/s/musing-cori-g9895q

💭 Expected behavior

When more products have been fetched with "Show more" button and user navigates to a product page and back, search results should still have all the previously fetched products visible.

Environment

felipe-muner commented 1 year ago

Hello,

Have u fixed it? Im having some typescript issues...

rodrigo-arias commented 11 months ago

Experiencing the same with react-instantsearch 7.1.0

timhonders commented 6 months ago

This is still a issue with react-instantsearch 7.7.0

When using this with Nextjs with app router this causesthat the hits array first gets filled with the date fetched fromt the server, then de hits array gets cleared and filled again.

This can cause u see a empty list flashing

We worked around this filling out own hits array outside of useInfiniteHits and clearing it when needed, this is not ideal but it works.

keighl commented 1 month ago

I think the problem here is mainly with the store using the entire state object as the cache key. That object can change a lot, especially during initial page load. For example, disjunctiveFacets configured at aloglia are loaded async and change the state object. It's a guaranteed cache miss and broken back-button UX unless it's a super simple situation like https://instantsearchjs.netlify.app/stories/js/?path=/story/results-infinitehits--with-sessionstorage-cache-enabled.

What I've done is implement a custom cache that keys by only the aspects of state that should cause a hit/miss. For my needs those are index optionalFilters and refinements.


import { SearchParameters } from 'algoliasearch-helper';
import { getRefinements } from 'instantsearch.js/es/lib/utils';

type CacheState = {
  hits: InfiniteHitsCachedHits<Product>;
  key: string;
};

const cache: InfiniteHitsCache = {
  read({ state }) {
    const searchParams = SearchParameters.make(state);
    const refinements = getRefinements({}, searchParams);
    const key = JSON.stringify([
      state.index,
      state.optionalFilters,
      refinements,
    ]);

    const item = sessionStorage.getItem('_infiniteHits');

    const hitsCache = item
      ? (JSON.parse(item) as CacheState)
      : null;

    return key === hitsCache?.key
      ? hitsCache.hits
      : null;
  },

  write({ state, hits }) {
    const searchParams = SearchParameters.make(state);
    const refinements = getRefinements({}, searchParams);
    const key = JSON.stringify([
      state.index,
      state.optionalFilters,
      refinements,
    ]);

    sessionStorage.setItem(
      '_infiniteHits',
      JSON.stringify({
        key,
        hits,
      })
    );
  },
};
Haroenv commented 1 month ago

If you only want to use the hits, you could also consider using result.queryId as the cache key, although that changes if the queries are different.