React-instantsearch: Refresh does not request pages in infinite hits again

Created on 6 Aug 2019  Β·  49Comments  Β·  Source: algolia/react-instantsearch

Describe the bug πŸ›

When updating the refresh token, infinite hits will not react to the refetch.

To Reproduce πŸ”

Steps to reproduce the behavior:

  1. go to the example
  2. trigger next page
  3. refresh
  4. see no changes in the infinite hits, even if the index would have changed

The example's index can be changed to an index you control where you make changes to make the effect clearer.

https://codesandbox.io/s/react-instantsearch-app-dq2ro

Expected behavior πŸ’­

Infinite hits reacts to refresh, either by:

  1. clearing the cache completely and restarting on the current page
  2. clearing the cache and the state and restarting on page 0

Likely we should go for option 2, unless there's a showPrevious.

Additional context

Add any other context about the problem here.

When refresh happens, we need to make sure the infinite hits internal cache is also invalidated.

On React Native, where the list component itself is stateful, we can not rely on the "key" hack, because it rerenders with an empty state when we simply clear the cache. What could be an option is:

  1. clear cache
  2. redo current search
  3. save in cache
  4. rerender

The problem is that you can't do that as a user reasonably, since you don't have access to the helper state.

A possible solution is:

In the function refresh in InstantSearchManager, emit an event to all widgets. Then in InfiniteHits, listen to that event, and clear the internal cache as we expect.

Another potential solution is to return a promise from the search which happens in refresh. This should allow people to rerender the InfiniteHits component manually.

Relevant pieces of code:

https://github.com/algolia/react-instantsearch/blob/ec9e0fbd6106d1c3e47f1dbfa6eaac3e20af6bd5/packages/react-instantsearch-core/src/core/createInstantSearchManager.js#L69-L72

Relevant issues:

https://github.com/algolia/react-instantsearch/issues/2464

Feedback

Most helpful comment

Yes, the connector will accept the cache prop as well @meck93. You can check that by using a custom cache which has some logging :)

All 49 comments

Hi all,

Just to give you a demonstration of what happens here if you refresh using a key. Unfortunately, you momentarily receive hits of length 0.

I have created an example to use ClearCache and a searchClient object - which does not work. https://snack.expo.io/@alexpchin/aglolia-refresh-problem
Please run on iOS and look out for the red flash.

The full code is here: https://github.com/alexpchin/algolia-refresh-issue/

The docs say this method is (browserOnly)?
https://www.algolia.com/doc/api-client/advanced/cache-browser-only/javascript/?language=javascript

I think it’s reasonable that the index updates and the results go back to page 0.

Thanks,
Alex

browserOnly implies not for node, it works in React Native. We are aware of this issue, but aren't sure what the solution could be unfortunately.

Without having a key, it behaves correctly, except for InfiniteHits, which keeps its cache and doesn't update. The problem is that we need to change things to be able to clear the cache on InfiniteHits too.

There's multiple options here:

  1. add an option to clear the internal cache of InfiniteHits (same problem though, when do you clear it?)
  2. Clear the internal cache of InfiniteHits when refresh is called (how?)

Thank you for the clarification. I believe I was originally the person who contacted RE: the problem and was just adding some more information so that people could see clearly the effect that is causes with the hits being momentarily empty when using a key to refresh.

Our project relies heavily on Algolia to display infinite results.

The idea solution would be that when refresh is given to InstantSearch, an event is emitted to the InfiniteHits so that we do not have to re-mount the component with a key.

To the user, I believe the disruption is only seen by hits length becoming momentarily 0
When the component is re-mounted.

Just to be 100% clear, what is the benefit of using the connectInfiniteHits connector over building a search using the Algolia Search API and passing filters to it? Something not dissimilar to (with a bit more work for pagination etc)?

import algoliasearch from 'algoliasearch/lite';
import * as C from 'src/constants';

const client = algoliasearch(C.ALGOLIA_APP_ID, C.ALGOLIA_API_KEY);

export const algoliaSearch = async ({
  indexName,
  query = '',
  filters = '',
  hitsPerPage = 20,
  attributesToRetrieve = ['*'],
}) => {
  try {
    const index = client.initIndex(indexName);
    const response = await index.search(query, {
      filters,
      attributesToRetrieve,
      hitsPerPage,
    });
    return response;
  } catch (err) {
    console.log(err);
    console.log(err.debugData);
  }
};

If all you are doing is showing results, there's no real advantage to use InfiniteHits in your case. Since we haven't fully solved "refresh" for InfiniteHits, it's a fair solution to fall back to the JS client.

To refresh on it, call client.clearCache and call your search/render function again.

Sorry for not yet fixing this refresh issue in the mean time :)

Hi all,

I was just wondering whether there was any update to this? I'm still having a little trouble "rolling my own" infinite hits using the API. My setup requires the ability to search, show no duplicated results, have infinite scroll etc. Everything with the ConnectInfiniteHits works except the problem stated above.

Thanks in advance,
Alex

Sorry @alexpchin, this is very complicated from our end to get something like this out the door, and it has not yet been prioritised as an issue essential to fix right now.

No problem! Thanks for the swift response πŸ‘

Hi @Haroenv any plans to implement this?

Not currently, since you're the only person who has noted this bug / wrong behaviour. I totally agree that it's a problem, but since the solution is unclear we don't have fixing this planned. Sorry!

Hi @Haroenv I'm also having this issue. Tried to implement scroll to top and refresh the search when user taps the tab bar. Using infinitehits on react native. Any workarounds besides re-mounting the entire search component?

You can remount the <InfiniteHits> component alone, at the same time as using refresh, which will clear its cache

Just dropping in to say I've encountered the same issue.

I have also encountered this issue. I use the pull to refresh of Flatlist with a callback which sets the refresh to true and remounts the <InfiniteHits>. The first time I pull it doesn't show an updated list but the second time I pull then it shows the updated list.

update: as a workaround, I remount the <InfiniteHits> after a second using setTimeout and it seems to be working. Not ideal but at least it works for now.

Refresh with InifiniteHits seems not to work

My only workaround was to clear cache of inifiniteHits manually using searchClient.clearCache()

You can control the internal cache of InfiniteHits now.

import isEqual from 'react-fast-compare';

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

function getInMemoryCache() {
  let cachedHits = undefined;
  let cachedState = undefined;
  return {
    read({ state }) {
      return isEqual(cachedState, getStateWithoutPage(state))
        ? cachedHits
        : null;
    },
    write({ state, hits }) {
      cachedState = getStateWithoutPage(state);
      cachedHits = hits;
    },
    clear() {
      cachedHits = undefined;
      cachedState = undefined;
    }
  };
}

const cache = getInMemoryCache();

<InfiniteHits 
  ...
  cache={cache}
/>

And later, you can call cache.clear() right before you refresh.

The internal cache of InfiniteHits should be cleared when refreshing. But, until it's properly fixed, that can be another workaround.

@eunjae-lee can this also be done when using connectInfiniteHits?
I didn't see anything in the documentation. Can I just pass it as an additional prop (see point 3)?

// 0. Initialize the cache
const sessionStorageCache = createInfiniteHitsSessionStorageCache();

// 1. Create a React component
const InfiniteHits = () => {
  // return the DOM output
};

// 2. Connect the component using the connector
const CustomInfiniteHits = connectInfiniteHits(InfiniteHits);

// 3. Use your connected widget
<CustomInfiniteHits cache={cache} />

Yes, the connector will accept the cache prop as well @meck93. You can check that by using a custom cache which has some logging :)

Great, thanks a lot πŸ‘

I am also encountering this issue. Spent hours at this point trying to figure out what's going on. @Haroenv if Algolia isn't going to fix it, it'd be really helpful to at least have a warning in the docs so that others don't find themselves wasting time like I did.

Also react-instantsearch-native does not appear to have the same ability to override the cache.

react native InstantSearch should work exactly the same, since the connector is just forwarded from the "core" version, so I expect something else is going on.

This is something we're planning to fix in the future, however I don't see how we can give a warning in this case unfortunately?

Hi @Haroenv I'm just circling back to this. I've managed to get this work on the web by using:

<CustomHits
  cache={createInfiniteHitsSessionStorageCache("ais.tattoos")}
/>

Combined with:

import _objectWithoutProperties from "@babel/runtime/helpers/esm/objectWithoutProperties"
import isEqual from "react-fast-compare"

function getStateWithoutPage(state) {
  var _ref = state || {},
    // page = _ref.page,
    rest = _objectWithoutProperties(_ref, ["page"])

  return rest
}

function hasSessionStorage() {
  return (
    typeof window !== "undefined" &&
    typeof window.sessionStorage !== "undefined"
  )
}

export default function createInfiniteHitsSessionStorageCache(
  KEY = "ais.infiniteHits"
) {
  return {
    read: function read(_ref2) {
      var state = _ref2.state

      if (!hasSessionStorage()) {
        return null
      }

      try {
        var cache = JSON.parse(window.sessionStorage.getItem(KEY))
        return cache && isEqual(cache.state, getStateWithoutPage(state))
          ? cache.hits
          : null
      } catch (error) {
        if (error instanceof SyntaxError) {
          try {
            window.sessionStorage.removeItem(KEY)
          } catch (err) {
            // do nothing
          }
        }

        return null
      }
    },
    write: function write(_ref3) {
      var state = _ref3.state,
        hits = _ref3.hits

      if (!hasSessionStorage()) {
        return
      }

      try {
        window.sessionStorage.setItem(
          KEY,
          JSON.stringify({
            state: getStateWithoutPage(state),
            hits: hits,
          })
        )
      } catch (error) {
        // do nothing
      }
    },
  }
}

This allows me to create a different store for each page.
However, I'm still a bit stuck on how best to implement this in react-native.

Do you have a guide for clearing the cache for multiple connectInfiniteHits and there no solution where we can clear the internal cache without having to create our own?

Thanks and Happy New Year!

Hi @Haroenv Thanks for the πŸ‘ Do you have a suggestion for resolving this in react-native? I still cannot seem to get things working well. Is it related to: https://github.com/algolia/react-instantsearch/issues/2995 ?

I was still looking what's going on, but I don't have a good react native sandbox and setup, so that took a while, and I had to work on other things. If you can recreate the bad behaviour in a GitHub example, that would help a lot!

Hi @Haroenv

I did make a github repository a while ago: https://github.com/algolia/react-instantsearch/issues/2742#issuecomment-523837227 However, it is a little outdated now so I made a new one here:

https://github.com/alexpchin/react-instant-search-refresh

You will need to change the APP_ID and Index values to one where you can delete an object from an index to see the behaviour.

Hi @Haroenv did you have any suggestion for this fix? Currently, I'm having to use the connectHits and loading a large number of results because I can't seem to get the cache to clear well on connectInfiniteHits in react-native.

can you contact [email protected] with the full reproduction? this way there will be a reminder to reply to you?

@Haroenv I will do that now.

Hello @Haroenv @eunjae-lee I've just updated to "react-instantsearch-native": "6.10.0" following #3011. It seems that the cache does clear if I use the cache prop with a custom cache:

import isEqual from 'react-fast-compare';

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

function getInMemoryCache() {
  let cachedHits = undefined;
  let cachedState = undefined;
  return {
    clear() {
      cachedHits = undefined;
      cachedState = undefined;
    },
    read({ state }) {
      return isEqual(cachedState, getStateWithoutPage(state))
        ? cachedHits
        : null;
    },
    write({ hits, state }) {
      cachedState = getStateWithoutPage(state);
      cachedHits = hits;
    },
  };
}

export const cache = getInMemoryCache();

However, it doesn't seem to clear if I don't use a cache prop. Is this expected with this fix?

@alexpchin If I understand your question correctly,
Yes, it's an existing issue that passing refresh to won't automatically send a signal to InfinitHits to clear its cache.
So in the meantime, when you pass true as refresh to , you also need to call cache.clear().
There was another problem that cache.clear() didn't work because cached hits were once again cached internally in connectInfiniteHits. That's fixed in #3011

Hi @eunjae-lee

When passing a custom cache (as per https://github.com/algolia/react-instantsearch/issues/2742#issuecomment-784258654) and then clearing that with cache.clear, the cache does clear. (Before #3011, this wasn't working).

However, my two questions are:

  • Do you need to provide a custom cache, should you be able to clear the built-in cache with searchClient.clearCache(). This doesn't seem to work? Creating a custom cache for multiple indexes etc is not very documented too so it would be good to be able to clear the in-built one to reduce complexity.
  • Do you need to import algoliasearch from algoliasearch or algoliasearch/lite?

Hello @alexpchin

Do you need to provide a custom cache, should you be able to clear the built-in cache with searchClient.clearCache(). This doesn't seem to work?

You're correct. It doesn't work yet. We haven't gone that far. It's a different topic than #3011, so I couldn't do it altogether. I'll discuss with the team, but in the meantime custom cache implementation is the workaround.

Do you need to import algoliasearch from algoliasearch or algoliasearch/lite?

You need algoliasearch/lite.

Thank you @eunjae-lee

Do you have a good example of a react-native custom cache for multiple indexes?

Just for anyone looking, this is what I have so far:

import isEqual from 'react-fast-compare';
import AsyncStorage from '@react-native-async-storage/async-storage';

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

const customCache = (key) => {
  return {
    clear() {
      AsyncStorage.removeItem(`cache${key}`);
    },
    read({ state }) {
      const cache = AsyncStorage.getItem(`cache${key}`);
      return cache && isEqual(cache.hits, getStateWithoutPage(state))
        ? cache.hits
        : null;
    },
    write({ hits, state }) {
      AsyncStorage.setItem(
        `cache${key}`,
        JSON.stringify({
          hits: hits,
          state: getStateWithoutPage(state),
        }),
      );
    },
  };
};

export const cache = (key) => customCache(key);

To be used with:

cache={cache('key')}

Thanks @alexpchin for providing your snippet!

Hi @alexpchin, one thing I'd like to remind you of is, if you pass cache like cache={cache('key')} it will create a fresh cache object everytime it renders. So I advise you to create the cache object and assign to a variable, and pass it to the InfiniteHits widget.

const c = cache('key');

<InfiniteHits
   ...
   cache={c}
/>

Thanks @eunjae-lee I've changed to use a variable instead. I'm facing an issue with my custom cache that the total hits stays the same and are replaced so I don't think my cache implementation is entirely correct. When I don't use the cache prop, the number of hits increases.

Ok, one of the issues is that AsyncStorage is async πŸ€¦β€β™‚οΈ (hence the name).

In addition in my app, I have multiple pages that search the same index. The InfiniteHits component is reuseable and therefore the cache needs to take that into account. I will add some more info if I get a better implementation working.

Thanks for the headsup @alexpchin
I'm curious why not using an object in memory and go for another storage implementation?

I just thought that using a storage implementation like AsyncStorage would persist? And clearing it from other parts of the codebase might be useful at some point. I could use an object in Memory like this?

import isEqual from 'react-fast-compare';

function getStateWithoutPage(state) {
  // eslint-disable-next-line no-unused-vars
  const { page, ...rest } = state || {};
  return rest;
}

function getInMemoryCache() {
  let cachedHits = undefined;
  let cachedState = undefined;
  return {
    clear() {
      cachedHits = undefined;
      cachedState = undefined;
    },
    read({ state }) {
      return isEqual(cachedState, getStateWithoutPage(state))
        ? cachedHits
        : null;
    },
    write({ hits, state }) {
      cachedState = getStateWithoutPage(state);
      cachedHits = hits;
    },
  };
}

const storage = {};

function buildCache(key) {
  if (storage[key]) {
    return storage[key];
  }
  storage[key] = getInMemoryCache();
  return storage[key];
}

export const cache = (key) => buildCache(key);

Is this more what you are talking about?

I don't think it needs to be persistent (I don't know how persistent it is, though). In my opinion, the memory cache will be sufficient for many cases unless you intentionally keep the cache for a long time.
And what I meant by the memory cache, that implementation is exactly what I had in mind.

Hi @eunjae-lee Do you by any chance have a working react-native example with a custom cache (as above) and connectInfiniteHits. I'm still struggling to get my code to work as expected.

I am using a Configure to pass a search filter, when using a custom cache, I don't seem to be getting the new hits adding into the hits, rather, they are replacing the existing ones.

@alexpchin Sorry but we don't have any working example yet. It seems like your state isn't consistent and InfiniteHits thinks it's a new state and overrode the cache. Can you add logs into your custom cache implementation and check how the state changes?

function getInMemoryCache() {
  let cachedHits = undefined;
  let cachedState = undefined;
  return {
    clear() {
      cachedHits = undefined;
      cachedState = undefined;
    },
    read({ state }) {
      return isEqual(cachedState, getStateWithoutPage(state))
        ? cachedHits
        : null;
    },
    write({ hits, state }) {
      cachedState = getStateWithoutPage(state);
      cachedHits = hits;
    },
  };
}

Hi, I'm just circling back to this as I was on other tasks. I have logged some things:

import isEqual from 'react-fast-compare';

function getStateWithoutPage(state) {
  // eslint-disable-next-line no-unused-vars
  const { page, ...rest } = state || {};
  return rest;
}

function getInMemoryCache() {
  let cachedHits = undefined;
  let cachedState = undefined;
  return {
    clear() {
      cachedHits = undefined;
      cachedState = undefined;
    },
    read({ state }) {
      console.log('cachedState', JSON.stringify(cachedState, null, 4));

      console.log(
        'getStateWithoutPage',
        JSON.stringify(getStateWithoutPage(state), null, 4),
      );

      return isEqual(cachedState, getStateWithoutPage(state))
        ? cachedHits
        : null;
    },
    write({ hits, state }) {
      cachedState = getStateWithoutPage(state);
      cachedHits = hits;

      console.log('cachedHits', JSON.stringify(cachedHits, null, 4));
    },
  };
}

const storage = {};

function buildCache(key) {
  if (storage[key]) {
    return storage[key];
  }
  storage[key] = getInMemoryCache();
  return storage[key];
}

export const cache = (key) => buildCache(key);

This is an example of what I am seeing for cachedState

{
    "indices": {
        "search-tattoos": {
            "configure": {
                "filters": "type:flash OR type:tattoo OR type:collection",
                "hitsPerPage": 21
            },
            "page": 2
        },
        "search-artists": {
            "configure": {
                "filters": "role:artist AND verified:\"true\" OR status:instagram",
                "hitsPerPage": 21
            },
            "page": 1
        },
        "search-studios": {
            "configure": {
                "filters": "role:studio AND verified:\"true\" OR status:instagram",
                "hitsPerPage": 21
            },
            "page": 1
        },
        "search-collectors": {
            "configure": {
                "filters": "role:customer",
                "hitsPerPage": 21
            },
            "page": 1
        }
    },
    "query": ""
}

So I updated the getStateWithoutPage to:

import omitDeep from 'omit-deep-lodash';

const getStateWithoutPage = (state = {}) => {
  return omitDeep(state, 'page');
};

This helped to remove the references to page in the nested object.

{
    "indices": {
        "search-artists": {
            "configure": {
                "filters": "role:artist AND verified:\"true\" OR status:instagram",
                "hitsPerPage": 21
            }
        },
        "search-studios": {
            "configure": {
                "filters": "role:studio AND verified:\"true\" OR status:instagram",
                "hitsPerPage": 21
            }
        },
        "search-collectors": {
            "configure": {
                "filters": "role:customer",
                "hitsPerPage": 21
            }
        },
        "photos-places": {},
        "search-tattoos": {
            "configure": {
                "filters": "type:flash OR type:tattoo OR type:collection",
                "hitsPerPage": 21
            }
        },
        "tags": {}
    },
    "query": ""
}

I am now just going through to test a little further...

@eunjae-lee

Hi all, I'm still struggling to get a good solution here... I have got most of it working with a custom cache, however, when updating the search query - my cache does not seem to display the correct results. They seem to be stale by one result. If I change to a controlled component:

<InstantSearch
  indexName={indexName}
  onSearchStateChange={onSearchStateChange}
  searchClient={searchClient}
  searchState={searchState}
>
.
.
</InstantSearch>

Then the query updates the search, but the connectInfiniteHits then hides the previous hits after calling refineNext.

Ideally, the refresh prop should just clear the internal cache. Or alternatively, clear by calling searchClient.clearCache()?
This way, we don't need to mess around with custom caches at all?

Update

I've just seen:

Do you have a GitHub repo with this latest state @alexpchin?

Hi @Haroenv I finally think I've sorted the issues. One of the issues was fixed with incorporating omit-deep-lodash to remove the page from all of the indices in the state (as I was using multi-index). Another was fixed by creating a cache using a key:

const storage = {};

function buildCache(key) {
  if (storage[key]) {
    return storage[key];
  }
  storage[key] = getInMemoryCache();
  return storage[key];
}

Then finally, your fix for #3018 seemed to fix the stale hits that I was seeing.

I am just releasing a new version of my project, after I do that, I will create an example to share.

Hello @alexpchin Sorry I was off. I'm glad it seems to have fixed your issue. Let us know how it goes!

Was this page helpful?
0 / 5 - 0 ratings

Related issues

afgoulart picture afgoulart  Β·  4Comments

noclat picture noclat  Β·  3Comments

markmiller21 picture markmiller21  Β·  3Comments

mthuret picture mthuret  Β·  5Comments

noclat picture noclat  Β·  3Comments