Skip to content

Commit

Permalink
fix(infinite-hits): do not write cache with incomplete state caused b…
Browse files Browse the repository at this point in the history
…y dynamic widgets (#5620)

Co-authored-by: Aymeric Giraudet <aymeric.giraudet@algolia.com>
  • Loading branch information
dhayab and aymeric-giraudet authored May 10, 2023
1 parent edcc1e8 commit 30edccd
Show file tree
Hide file tree
Showing 2 changed files with 138 additions and 1 deletion.
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,7 @@ import {
createRenderOptions,
} from '../../../../test/createWidget';
import instantsearch from '../../../index.es';
import { createInfiniteHitsSessionStorageCache } from '../../../lib/infiniteHitsCache';
import { TAG_PLACEHOLDER, deserializePayload } from '../../../lib/utils';
import connectInfiniteHits from '../connectInfiniteHits';

Expand Down Expand Up @@ -869,6 +870,120 @@ See documentation: https://www.algolia.com/doc/api-reference/widgets/infinite-hi
expect((results.hits as unknown as EscapedHits).__escaped).toBe(true);
});

it('does not overwrite custom cache when dynamic widgets have no facets in state yet', () => {
const sessionStorageCache = createInfiniteHitsSessionStorageCache();

function getInstance() {
const renderFn = jest.fn();
const makeWidget = connectInfiniteHits(renderFn);
const widget = makeWidget({ cache: sessionStorageCache });

const helper = algoliasearchHelper({} as SearchClient, '', {});
helper.search = jest.fn();

const instantSearchInstance = createInstantSearch();
instantSearchInstance.mainIndex.addWidgets([
{ $$type: 'ais.dynamicWidgets', init() {} },
]);

const initOptions = createInitOptions({
state: helper.state,
helper,
instantSearchInstance,
});

widget.init!(initOptions);

const renderWidget = (
args: Partial<ReturnType<typeof createRenderOptions>>
) => {
const renderOptions = createRenderOptions({
state: helper.state,
helper,
instantSearchInstance,
...args,
});

widget.render!(renderOptions);
};
return { helper, renderFn, renderWidget };
}

const firstPageHits = [{ objectID: '1' }, { objectID: '2' }];
const secondPageHits = [{ objectID: '3' }, { objectID: '4' }];

// Load InstantSearch
{
const { helper, renderFn, renderWidget } = getInstance();

// Render: page 1
let searchResults = new SearchResults(helper.state, [
createSingleSearchResponse({ hits: firstPageHits }),
]);
renderWidget({ results: searchResults });

// Simulate facets added to state by Dynamic Widgets
helper.setState(helper.state.addFacet('brand'));

// Rerender: page 1
searchResults = new SearchResults(helper.state, [
createSingleSearchResponse({
facets: { brand: { Apple: 100 } },
hits: firstPageHits,
}),
]);
renderWidget({ results: searchResults });

let renderOptions = renderFn.mock.calls[2][0];
expect(renderOptions.hits).toEqual(firstPageHits);
expect(renderOptions.results).toEqual(searchResults);

// Search: page 2
renderOptions.showMore();
expect(helper.search).toHaveBeenCalledTimes(1);

// Render: page 2
searchResults = new SearchResults(helper.state, [
createSingleSearchResponse({
facets: { brand: { Apple: 100 } },
hits: secondPageHits,
}),
]);
renderWidget({ results: searchResults });

renderOptions = renderFn.mock.calls[3][0];
expect(renderOptions.hits).toEqual([...firstPageHits, ...secondPageHits]);
expect(renderOptions.results).toEqual(searchResults);
}

// Refresh InstantSearch
{
const { helper, renderFn, renderWidget } = getInstance();

// Render: page 2
let searchResults = new SearchResults(helper.state, [
createSingleSearchResponse({ hits: secondPageHits }),
]);
renderWidget({ results: searchResults });

// Simulate facets added to state by Dynamic Widgets
helper.setState(helper.state.addFacet('brand'));

// Rerender: page 2
searchResults = new SearchResults(helper.state, [
createSingleSearchResponse({
facets: { brand: { Apple: 100 } },
hits: secondPageHits,
}),
]);
renderWidget({ results: searchResults });

const renderOptions = renderFn.mock.calls[2][0];
expect(renderOptions.hits).toEqual([...firstPageHits, ...secondPageHits]);
expect(renderOptions.results).toEqual(searchResults);
}
});

describe('dispose', () => {
it('calls the unmount function', () => {
const helper = algoliasearchHelper({} as SearchClient, '', {});
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@ import {
noop,
createSendEventForHits,
createBindEventForHits,
walkIndex,
} from '../../lib/utils';

import type { SendEventForHits, BindEventForHits } from '../../lib/utils';
Expand Down Expand Up @@ -355,10 +356,31 @@ const connectInfiniteHits: InfiniteHitsConnector = function connectInfiniteHits(
{ results }
);

/*
With dynamic widgets, facets are not included in the state before their relevant widgets are mounted. Until then, we need to bail out of writing this incomplete state representation in cache.
*/
let hasDynamicWidgets = false;
walkIndex(instantSearchInstance.mainIndex, (indexWidget) => {
if (
!hasDynamicWidgets &&
indexWidget
.getWidgets()
.some(({ $$type }) => $$type === 'ais.dynamicWidgets')
) {
hasDynamicWidgets = true;
}
});

const hasNoFacets =
!results.disjunctiveFacets?.length &&
!results.facets?.length &&
!results.hierarchicalFacets?.length;

if (
cachedHits[page] === undefined &&
!results.__isArtificial &&
instantSearchInstance.status === 'idle'
instantSearchInstance.status === 'idle' &&
!(hasDynamicWidgets && hasNoFacets)
) {
cachedHits[page] = transformedHits;
cache.write({ state: normalizeState(state), hits: cachedHits });
Expand Down

0 comments on commit 30edccd

Please sign in to comment.