Skip to content

Commit

Permalink
fix(infiniteHits): do not cache the cached hits inside the connector (#…
Browse files Browse the repository at this point in the history
…4534)

* fix(infiniteHits): do not cache the cached hits inside the connector

* remove exclamation marks

* remove unused code that was for comparing cache in the past

* do not store firstReceivedPage or lastReceivedPage in the upper scope

* provide cachedHits to page helper functions

* remove irrelevant comment
  • Loading branch information
Eunjae Lee authored Oct 21, 2020
1 parent c93e1cf commit c97395e
Show file tree
Hide file tree
Showing 2 changed files with 205 additions and 83 deletions.
121 changes: 51 additions & 70 deletions src/connectors/infinite-hits/connectInfiniteHits.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<SearchParameters>;
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(),
Expand All @@ -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,
Expand All @@ -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);
Expand All @@ -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,
Expand Down
167 changes: 154 additions & 13 deletions src/widgets/infinite-hits/__tests__/infinite-hits-integration-test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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({
Expand All @@ -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(() => {
Expand All @@ -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([
Expand Down Expand Up @@ -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: `<div>{{title}}</div>`,
},
}),
]);
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', () => {
Expand Down

0 comments on commit c97395e

Please sign in to comment.