diff --git a/src/connectors/infinite-hits/connectInfiniteHits.ts b/src/connectors/infinite-hits/connectInfiniteHits.ts index 5c40135d37..ff9de476c2 100644 --- a/src/connectors/infinite-hits/connectInfiniteHits.ts +++ b/src/connectors/infinite-hits/connectInfiniteHits.ts @@ -153,50 +153,64 @@ const connectInfiniteHits: InfiniteHitsConnector = function connectInfiniteHits( transformItems = (items: any[]) => items, cache = getInMemoryCache(), } = widgetParams || ({} as typeof widgetParams); - let cachedHits: InfiniteHitsCachedHits | undefined = undefined; - let prevState: Partial; let showPrevious: () => void; let showMore: () => void; let sendEvent; let bindEvent; + const getFirstReceivedPage = ( + state: SearchParameters, + cachedHits: InfiniteHitsCachedHits + ) => { + const { page = 0 } = state; + const pages = Object.keys(cachedHits).map(Number); + if (pages.length === 0) { + return page; + } else { + return Math.min(page, ...pages); + } + }; + const getLastReceivedPage = ( + state: SearchParameters, + cachedHits: InfiniteHitsCachedHits + ) => { + const { page = 0 } = state; + const pages = Object.keys(cachedHits).map(Number); + if (pages.length === 0) { + return page; + } else { + return Math.max(page, ...pages); + } + }; - const getFirstReceivedPage = () => - Math.min(...Object.keys(cachedHits || {}).map(Number)); - const getLastReceivedPage = () => - Math.max(...Object.keys(cachedHits || {}).map(Number)); - - const getShowPrevious = (helper: Helper): (() => void) => () => { + const getShowPrevious = ( + helper: Helper, + cachedHits: InfiniteHitsCachedHits + ): (() => void) => () => { // Using the helper's `overrideStateWithoutTriggeringChangeEvent` method // avoid updating the browser URL when the user displays the previous page. helper .overrideStateWithoutTriggeringChangeEvent({ ...helper.state, - page: getFirstReceivedPage() - 1, + page: getFirstReceivedPage(helper.state, cachedHits) - 1, }) .searchWithoutTriggeringOnStateChange(); }; - const getShowMore = (helper: Helper): (() => void) => () => { - helper.setPage(getLastReceivedPage() + 1).search(); - }; - const filterEmptyRefinements = (refinements = {}) => { - return Object.keys(refinements) - .filter(key => - Array.isArray(refinements[key]) - ? refinements[key].length - : Object.keys(refinements[key]).length - ) - .reduce((obj, key) => { - obj[key] = refinements[key]; - return obj; - }, {}); + const getShowMore = ( + helper: Helper, + cachedHits: InfiniteHitsCachedHits + ): (() => void) => () => { + helper + .setPage(getLastReceivedPage(helper.state, cachedHits) + 1) + .search(); }; return { $$type: 'ais.infiniteHits', init({ instantSearchInstance, helper }) { - showPrevious = getShowPrevious(helper); - showMore = getShowMore(helper); + const cachedHits = cache.read({ state: helper.state }) || {}; + showPrevious = getShowPrevious(helper, cachedHits); + showMore = getShowMore(helper, cachedHits); sendEvent = createSendEventForHits({ instantSearchInstance, index: helper.getIndex(), @@ -209,16 +223,15 @@ const connectInfiniteHits: InfiniteHitsConnector = function connectInfiniteHits( renderFn( { - hits: extractHitsFromCachedHits( - cache.read({ state: helper.state }) || {} - ), + hits: extractHitsFromCachedHits(cachedHits), results: undefined, sendEvent, bindEvent, showPrevious, showMore, isFirstPage: - getFirstReceivedPage() === 0 || helper.state.page === undefined, + helper.state.page === undefined || + getFirstReceivedPage(helper.state, cachedHits) === 0, isLastPage: true, instantSearchInstance, widgetParams, @@ -228,38 +241,7 @@ const connectInfiniteHits: InfiniteHitsConnector = function connectInfiniteHits( }, render({ results, state, instantSearchInstance }) { - // Reset cache and received pages if anything changes in the - // search state, except for the page. - // - // We're doing this to "reset" the widget if a refinement or the - // query changes between renders, but we want to keep it as is - // if we only change pages. - const { - page = 0, - facets, - hierarchicalFacets, - disjunctiveFacets, - maxValuesPerFacet, - ...currentState - } = state; - - currentState.facetsRefinements = filterEmptyRefinements( - currentState.facetsRefinements - ); - currentState.hierarchicalFacetsRefinements = filterEmptyRefinements( - currentState.hierarchicalFacetsRefinements - ); - currentState.disjunctiveFacetsRefinements = filterEmptyRefinements( - currentState.disjunctiveFacetsRefinements - ); - currentState.numericRefinements = filterEmptyRefinements( - currentState.numericRefinements - ); - - if (!isEqual(currentState, prevState)) { - cachedHits = cache.read({ state }) || {}; - prevState = currentState; - } + const { page = 0 } = state; if (escapeHTML && results.hits.length > 0) { results.hits = escapeHits(results.hits); @@ -281,23 +263,22 @@ const connectInfiniteHits: InfiniteHitsConnector = function connectInfiniteHits( // hits widgets mounted on the page. (results.hits as any).__escaped = initialEscaped; - if (cachedHits === undefined) { - cachedHits = cache.read({ state }) || {}; - } - - if (cachedHits![page] === undefined) { - cachedHits![page] = results.hits; + const cachedHits = cache.read({ state }) || {}; + if (cachedHits[page] === undefined) { + cachedHits[page] = results.hits; cache.write({ state, hits: cachedHits }); } - const isFirstPage = getFirstReceivedPage() === 0; - const isLastPage = results.nbPages <= getLastReceivedPage() + 1; + const firstReceivedPage = getFirstReceivedPage(state, cachedHits); + const lastReceivedPage = getLastReceivedPage(state, cachedHits); + const isFirstPage = firstReceivedPage === 0; + const isLastPage = results.nbPages <= lastReceivedPage + 1; sendEvent('view', cachedHits[page]); renderFn( { - hits: extractHitsFromCachedHits(cachedHits!), + hits: extractHitsFromCachedHits(cachedHits), results, sendEvent, bindEvent, diff --git a/src/widgets/infinite-hits/__tests__/infinite-hits-integration-test.ts b/src/widgets/infinite-hits/__tests__/infinite-hits-integration-test.ts index 50639be096..3baf55744b 100644 --- a/src/widgets/infinite-hits/__tests__/infinite-hits-integration-test.ts +++ b/src/widgets/infinite-hits/__tests__/infinite-hits-integration-test.ts @@ -47,19 +47,15 @@ describe('infiniteHits', () => { }); describe('cache', () => { - let cachedState: any; - let cachedHits: any; - let customCache; - - beforeEach(() => { + function createCustomCache() { const getStateWithoutPage = state => { const { page, ...rest } = state || {}; return rest; }; const isEqual = (a, b) => JSON.stringify(a) === JSON.stringify(b); - cachedState = undefined; - cachedHits = undefined; - customCache = { + let cachedState = undefined; + let cachedHits = undefined; + const customCache: any = { read: jest.fn(({ state }) => { return isEqual(cachedState, getStateWithoutPage(state)) ? cachedHits @@ -69,11 +65,22 @@ describe('infiniteHits', () => { cachedState = getStateWithoutPage(state); cachedHits = hits; }), + clear: jest.fn(() => { + cachedState = undefined; + cachedHits = undefined; + }), }; - }); - it('calls read & write methods of custom cache', async () => { + return { + cachedState, + cachedHits, + customCache, + }; + } + + it('writes to a custom cache', async () => { const { search } = createInstantSearch(); + const { customCache } = createCustomCache(); search.addWidgets([ infiniteHits({ @@ -89,6 +96,23 @@ describe('infiniteHits', () => { ).length; expect(numberOfHits).toEqual(2); }); + expect(customCache.write).toHaveBeenCalledTimes(1); + expect(customCache.write.mock.calls[0][0].hits).toMatchInlineSnapshot(` + Object { + "0": Array [ + Object { + "__position": 1, + "objectID": "object-id0", + "title": "title 1", + }, + Object { + "__position": 2, + "objectID": "object-id1", + "title": "title 2", + }, + ], + } + `); fireEvent.click(getByText(container, 'Show more results')); await waitFor(() => { @@ -97,13 +121,40 @@ describe('infiniteHits', () => { ).length; expect(numberOfHits).toEqual(4); }); - - expect(customCache.read).toHaveBeenCalledTimes(2); // init & render - expect(customCache.write).toHaveBeenCalledTimes(2); // page #0, page #1 + expect(customCache.write).toHaveBeenCalledTimes(2); + expect(customCache.write.mock.calls[1][0].hits).toMatchInlineSnapshot(` +Object { + "0": Array [ + Object { + "__position": 1, + "objectID": "object-id0", + "title": "title 1", + }, + Object { + "__position": 2, + "objectID": "object-id1", + "title": "title 2", + }, + ], + "1": Array [ + Object { + "__position": 3, + "objectID": "object-id0", + "title": "title 3", + }, + Object { + "__position": 4, + "objectID": "object-id1", + "title": "title 4", + }, + ], +} +`); }); it('displays all the hits from cache', async () => { const { search, searchClient } = createInstantSearch(); + const { customCache } = createCustomCache(); // flow #1 - load page #0 & #1 to fill the cache search.addWidgets([ @@ -152,6 +203,96 @@ describe('infiniteHits', () => { expect(numberOfHits).toEqual(4); // it loads two pages initially }); }); + + it('works after the cache gets invalidated', async () => { + const { search } = createInstantSearch(); + const { customCache } = createCustomCache(); + + customCache.write({ + state: { + facets: [], + disjunctiveFacets: [], + hierarchicalFacets: [], + facetsRefinements: {}, + facetsExcludes: {}, + disjunctiveFacetsRefinements: {}, + numericRefinements: {}, + tagRefinements: [], + hierarchicalFacetsRefinements: {}, + index: 'instant_search', + hitsPerPage: 2, + highlightPreTag: '__ais-highlight__', + highlightPostTag: '__/ais-highlight__', + page: 0, + }, + hits: { + 0: [ + { + title: 'fake1', + objectID: 'test-object-id1', + }, + { + title: 'fake2', + objectID: 'test-object-id2', + }, + { + title: 'fake3', + objectID: 'test-object-id3', + }, + ], + }, + }); + + search.addWidgets([ + infiniteHits({ + container, + cache: customCache, // render with fake hits + templates: { + item: `
{{title}}
`, + }, + }), + ]); + search.start(); + + // waits until it renders + await waitFor(() => { + const numberOfHits = container.querySelectorAll( + '.ais-InfiniteHits-item' + ).length; + expect(numberOfHits).toEqual(3); + }); + + // checks if the fake hits are rendered + getByText(container, 'fake1'); + getByText(container, 'fake2'); + getByText(container, 'fake3'); + + // clears the cache + customCache.clear(); + search.refresh(); + + // waits until it renders the real hits + await waitFor(() => { + const numberOfHits = container.querySelectorAll( + '.ais-InfiniteHits-item' + ).length; + expect(numberOfHits).toEqual(2); + }); + + // checks if the correct hits are rendered + getByText(container, 'title 1'); + getByText(container, 'title 2'); + + expect(() => { + getByText(container, 'fake1'); + }).toThrowError( + expect.objectContaining({ + message: expect.stringContaining( + `Unable to find an element with the text: fake1.` + ), + }) + ); + }); }); describe('insights', () => {