Skip to content
This repository has been archived by the owner on Dec 30, 2022. It is now read-only.

Commit

Permalink
feat(infinite-hits): support cache (#2921)
Browse files Browse the repository at this point in the history
Co-authored-by: Yannick Croissant <yannick.croissant@algolia.com>
  • Loading branch information
Eunjae Lee and Yannick Croissant authored Jun 8, 2020
1 parent b542b0d commit 7b26adc
Show file tree
Hide file tree
Showing 8 changed files with 252 additions and 27 deletions.
3 changes: 2 additions & 1 deletion .codesandbox/ci.json
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
{
"packages": ["packages/*"],
"sandboxes": [
"github/algolia/create-instantsearch-app/tree/templates/react-instantsearch"
"github/algolia/create-instantsearch-app/tree/templates/react-instantsearch",
"github/algolia/doc-code-samples/tree/master/React InstantSearch/routing-basic"
]
}
6 changes: 3 additions & 3 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -142,15 +142,15 @@
},
{
"path": "packages/react-instantsearch/dist/umd/Dom.min.js",
"maxSize": "34.25 kB"
"maxSize": "34.30 kB"
},
{
"path": "packages/react-instantsearch-core/dist/umd/ReactInstantSearchCore.min.js",
"maxSize": "25.25 kB"
"maxSize": "25.35 kB"
},
{
"path": "packages/react-instantsearch-dom/dist/umd/ReactInstantSearchDOM.min.js",
"maxSize": "36.75 kB"
"maxSize": "36.90 kB"
},
{
"path": "packages/react-instantsearch-dom-maps/dist/umd/ReactInstantSearchDOMMaps.min.js",
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -29,6 +29,36 @@ function getCurrentRefinement(props, searchState, context) {
return currentRefinement;
}

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;
},
};
}

function extractHitsFromCachedHits(cachedHits) {
return Object.keys(cachedHits)
.map(Number)
.sort((a, b) => a - b)
.reduce((acc, page) => {
return acc.concat(cachedHits[page]);
}, []);
}

/**
* InfiniteHits connector provides the logic to create connected
* components that will render an continuous list of results retrieved from
Expand All @@ -48,12 +78,16 @@ export default createConnector({
multiIndexContext: props.indexContextValue,
});

this._allResults = this._allResults || [];
this._prevState = this._prevState || {};

const cache = props.cache || getInMemoryCache();
if (this._cachedHits === undefined) {
this._cachedHits = cache.read({ state: searchState }) || {};
}

if (!results) {
return {
hits: [],
hits: extractHitsFromCachedHits(this._cachedHits),
hasPrevious: false,
hasMore: false,
refine: () => {},
Expand All @@ -76,32 +110,34 @@ export default createConnector({
results.queryID
);

if (
this._firstReceivedPage === undefined ||
!isEqual(currentState, this._prevState)
) {
this._allResults = [...hitsWithPositionsAndQueryID];
this._firstReceivedPage = page;
this._lastReceivedPage = page;
} else if (this._lastReceivedPage < page) {
this._allResults = [...this._allResults, ...hitsWithPositionsAndQueryID];
this._lastReceivedPage = page;
} else if (this._firstReceivedPage > page) {
this._allResults = [...hitsWithPositionsAndQueryID, ...this._allResults];
this._firstReceivedPage = page;
if (!isEqual(currentState, this._prevState)) {
this._cachedHits = cache.read({ state: searchState }) || {};
}
if (this._cachedHits[page] === undefined) {
this._cachedHits[page] = hitsWithPositionsAndQueryID;
cache.write({ state: searchState, hits: this._cachedHits });
}

this._prevState = currentState;
/*
Math.min() and Math.max() returns Infinity or -Infinity when no argument is given.
But there is always something in this point because of `this._cachedHits[page]`.
*/
const firstReceivedPage = Math.min(
...Object.keys(this._cachedHits).map(Number)
);
const lastReceivedPage = Math.max(
...Object.keys(this._cachedHits).map(Number)
);

const hasPrevious = this._firstReceivedPage > 0;
const hasPrevious = firstReceivedPage > 0;
const lastPageIndex = nbPages - 1;
const hasMore = page < lastPageIndex;
const refinePrevious = event =>
this.refine(event, this._firstReceivedPage - 1);
const refineNext = event => this.refine(event, this._lastReceivedPage + 1);
const hasMore = lastReceivedPage < lastPageIndex;
const refinePrevious = event => this.refine(event, firstReceivedPage - 1);
const refineNext = event => this.refine(event, lastReceivedPage + 1);

return {
hits: this._allResults,
hits: extractHitsFromCachedHits(this._cachedHits),
hasPrevious,
hasMore,
refinePrevious,
Expand All @@ -120,8 +156,13 @@ export default createConnector({
},

refine(props, searchState, event, index) {
if (index === undefined && this._lastReceivedPage !== undefined) {
index = this._lastReceivedPage + 1;
const pages = Object.keys(this._cachedHits || {}).map(Number);
const lastReceivedPage =
pages.length === 0 ? undefined : Math.max(...pages);
// If there is no key in `this._cachedHits`,
// then `lastReceivedPage` should be `undefined`.
if (index === undefined && lastReceivedPage !== undefined) {
index = lastReceivedPage + 1;
} else if (index === undefined) {
index = getCurrentRefinement(props, searchState, {
ais: props.contextValue,
Expand Down
3 changes: 3 additions & 0 deletions packages/react-instantsearch-dom/src/index.js
Original file line number Diff line number Diff line change
Expand Up @@ -77,3 +77,6 @@ export { default as createVoiceSearchHelper } from './lib/voiceSearchHelper';
export {
default as getInsightsAnonymousUserToken,
} from './core/getInsightsAnonymousUserToken';

// InfiniteHits Cache
export { createInfiniteHitsSessionStorageCache } from './lib/infiniteHitsCache';
Original file line number Diff line number Diff line change
@@ -0,0 +1,94 @@
import createInfiniteHitsSessionStorageCache from '../sessionStorage';

const KEY = 'ais.infiniteHits';

describe('createInfiniteHitsSessionStorageCache', () => {
const originalSessionStorage = window.sessionStorage;
delete window.sessionStorage;

let store = {};
const getItem = jest.fn(key => store[key]);
const setItem = jest.fn((key, value) => {
store[key] = value;
});
const removeItem = jest.fn(key => delete store[key]);
const defaultHits = [
{ objectID: 'a', __position: 0 },
{ objectID: 'b', __position: 1 },
{ objectID: 'c', __position: 2 },
];

beforeAll(() => {
Object.defineProperty(window, 'sessionStorage', {
value: {
getItem,
setItem,
removeItem,
},
});
});

beforeEach(() => {
store = {};
});

afterEach(() => {
getItem.mockClear();
setItem.mockClear();
removeItem.mockClear();
});

afterAll(() => {
window.sessionStorage = originalSessionStorage;
});

it('returns null initially', () => {
const cache = createInfiniteHitsSessionStorageCache();
expect(cache.read({ state: {} })).toBeNull();
});

it('returns what it was assigned before', () => {
const cache = createInfiniteHitsSessionStorageCache();
const state = { query: 'hello' };
const hits = {
1: defaultHits,
};
cache.write({ state, hits });
expect(cache.read({ state })).toEqual(hits);
});

it('returns null if the state is different', () => {
const cache = createInfiniteHitsSessionStorageCache();
const state = { query: 'hello' };
const newState = { query: 'world' };
const hits = { 1: defaultHits };
cache.write({ state, hits });
expect(cache.read({ state: newState })).toBeNull();
});

it('clears cache if fails to read', () => {
getItem.mockImplementation(() => '{invalid_json}');
const cache = createInfiniteHitsSessionStorageCache();
cache.read({ state: {} });
expect(removeItem).toHaveBeenCalledTimes(1);
expect(removeItem).toHaveBeenCalledWith(KEY);
});

it('returns null if sessionStorage.getItem() throws', () => {
getItem.mockImplementation(() => {
throw new Error();
});
const cache = createInfiniteHitsSessionStorageCache();
expect(cache.read({ state: {} })).toBeNull();
});

it('does nothing if sessionStorage.setItem() throws', () => {
setItem.mockImplementation(() => {
throw new Error();
});
const cache = createInfiniteHitsSessionStorageCache();
expect(() => {
cache.write({ state: {}, hits: [] });
}).not.toThrow();
});
});
Original file line number Diff line number Diff line change
@@ -0,0 +1,3 @@
export {
default as createInfiniteHitsSessionStorageCache,
} from './sessionStorage';
Original file line number Diff line number Diff line change
@@ -0,0 +1,56 @@
import isEqual from 'react-fast-compare';

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

const KEY = 'ais.infiniteHits';

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

export default function createInfiniteHitsSessionStorageCache() {
return {
read({ state }) {
if (!hasSessionStorage()) {
return null;
}
try {
const 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({ state, hits }) {
if (!hasSessionStorage()) {
return;
}
try {
window.sessionStorage.setItem(
KEY,
JSON.stringify({
state: getStateWithoutPage(state),
hits,
})
);
} catch (error) {
// do nothing
}
},
};
}
27 changes: 27 additions & 0 deletions stories/InfiniteHits.stories.js
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@ import {
Snippet,
Configure,
connectInfiniteHits,
createInfiniteHitsSessionStorageCache,
} from 'react-instantsearch-dom';
import { WrapWithHits } from './util';
import { action } from '@storybook/addon-actions';
Expand Down Expand Up @@ -147,4 +148,30 @@ stories
<InfiniteHits hitComponent={ProductWithInsights} />
</WrapWithHits>
);
})
.add('with sessionStorage cache', () => {
function Product({ hit }) {
return (
<div>
<span>#{hit.__position}. </span>
<Highlight attribute="name" hit={hit} />
<p>
<a href="https://google.com">Details</a>
</p>
</div>
);
}
Product.propTypes = {
hit: PropTypes.object.isRequired,
};

return (
<WrapWithHits linkedStoryGroup="InfiniteHits">
<Configure hitsPerPage={16} />
<InfiniteHits
hitComponent={Product}
cache={createInfiniteHitsSessionStorageCache()}
/>
</WrapWithHits>
);
});

0 comments on commit 7b26adc

Please sign in to comment.