diff --git a/bundlesize.config.json b/bundlesize.config.json index 687aaa8fb2..586e9cd6dc 100644 --- a/bundlesize.config.json +++ b/bundlesize.config.json @@ -14,7 +14,7 @@ }, { "path": "packages/react-instantsearch/dist/umd/Connectors.min.js", - "maxSize": "25.75 kB" + "maxSize": "26 kB" }, { "path": "packages/react-instantsearch/dist/umd/Dom.min.js", diff --git a/packages/instantsearch.js/package.json b/packages/instantsearch.js/package.json index 92122ffd90..eb6430e5a0 100644 --- a/packages/instantsearch.js/package.json +++ b/packages/instantsearch.js/package.json @@ -32,7 +32,7 @@ "@types/google.maps": "^3.45.3", "@types/hogan.js": "^3.0.0", "@types/qs": "^6.5.3", - "algoliasearch-helper": "^3.11.2", + "algoliasearch-helper": "^3.11.3", "hogan.js": "^3.0.2", "htm": "^3.0.0", "preact": "^10.10.0", @@ -56,6 +56,7 @@ "devDependencies": { "@instantsearch/mocks": "1.0.3", "@instantsearch/testutils": "1.0.3", + "@instantsearch/tests": "1.0.3", "@storybook/html": "5.3.9", "@types/scriptjs": "0.0.2", "algoliasearch": "4.14.3", diff --git a/packages/instantsearch.js/src/__tests__/common.test.tsx b/packages/instantsearch.js/src/__tests__/common.test.tsx new file mode 100644 index 0000000000..70a484838e --- /dev/null +++ b/packages/instantsearch.js/src/__tests__/common.test.tsx @@ -0,0 +1,55 @@ +/** + * @jest-environment jsdom + */ +import { + createHierarchicalMenuTests, + createRefinementListTests, + createPaginationTests, + createMenuTests, +} from '@instantsearch/tests'; +import instantsearch from '../index.es'; +import { hierarchicalMenu, menu, refinementList, pagination } from '../widgets'; + +createHierarchicalMenuTests(({ instantSearchOptions, widgetParams }) => { + instantsearch(instantSearchOptions) + .addWidgets([ + hierarchicalMenu({ + container: document.body.appendChild(document.createElement('div')), + ...widgetParams, + }), + ]) + .start(); +}); + +createRefinementListTests(({ instantSearchOptions, widgetParams }) => { + instantsearch(instantSearchOptions) + .addWidgets([ + refinementList({ + container: document.body.appendChild(document.createElement('div')), + ...widgetParams, + }), + ]) + .start(); +}); + +createMenuTests(({ instantSearchOptions, widgetParams }) => { + instantsearch(instantSearchOptions) + .addWidgets([ + menu({ + container: document.body.appendChild(document.createElement('div')), + ...widgetParams, + }), + ]) + .start(); +}); + +createPaginationTests(({ instantSearchOptions, widgetParams }) => { + instantsearch(instantSearchOptions) + .addWidgets([ + pagination({ + container: document.body.appendChild(document.createElement('div')), + ...widgetParams, + }), + ]) + .start(); +}); diff --git a/packages/instantsearch.js/src/components/Pagination/Pagination.tsx b/packages/instantsearch.js/src/components/Pagination/Pagination.tsx index 423990be59..14ac8d6750 100644 --- a/packages/instantsearch.js/src/components/Pagination/Pagination.tsx +++ b/packages/instantsearch.js/src/components/Pagination/Pagination.tsx @@ -81,7 +81,7 @@ function Pagination(props: PaginationProps) { {props.pages.map((pageNumber) => ( @@ -23,7 +23,7 @@ exports[`Pagination should add the noRefinement CSS class with a single page 1`] class="item pageItem" > @@ -34,7 +34,7 @@ exports[`Pagination should add the noRefinement CSS class with a single page 1`] class="item pageItem" > @@ -45,7 +45,7 @@ exports[`Pagination should add the noRefinement CSS class with a single page 1`] class="item pageItem" > @@ -56,7 +56,7 @@ exports[`Pagination should add the noRefinement CSS class with a single page 1`] class="item pageItem" > @@ -67,7 +67,7 @@ exports[`Pagination should add the noRefinement CSS class with a single page 1`] class="item pageItem" > @@ -78,7 +78,7 @@ exports[`Pagination should add the noRefinement CSS class with a single page 1`] class="item pageItem" > @@ -120,7 +120,7 @@ exports[`Pagination should disable last page if already on it 1`] = ` class="item pageItem" > @@ -131,7 +131,7 @@ exports[`Pagination should disable last page if already on it 1`] = ` class="item pageItem" > @@ -142,7 +142,7 @@ exports[`Pagination should disable last page if already on it 1`] = ` class="item pageItem" > @@ -153,7 +153,7 @@ exports[`Pagination should disable last page if already on it 1`] = ` class="item pageItem" > @@ -164,7 +164,7 @@ exports[`Pagination should disable last page if already on it 1`] = ` class="item pageItem" > @@ -175,7 +175,7 @@ exports[`Pagination should disable last page if already on it 1`] = ` class="item pageItem" > @@ -186,7 +186,7 @@ exports[`Pagination should disable last page if already on it 1`] = ` class="item pageItem selectedItem" > @@ -231,7 +231,7 @@ exports[`Pagination should display the first/last link 1`] = ` class="item pageItem selectedItem" > @@ -242,7 +242,7 @@ exports[`Pagination should display the first/last link 1`] = ` class="item pageItem" > @@ -253,7 +253,7 @@ exports[`Pagination should display the first/last link 1`] = ` class="item pageItem" > @@ -264,7 +264,7 @@ exports[`Pagination should display the first/last link 1`] = ` class="item pageItem" > @@ -275,7 +275,7 @@ exports[`Pagination should display the first/last link 1`] = ` class="item pageItem" > @@ -286,7 +286,7 @@ exports[`Pagination should display the first/last link 1`] = ` class="item pageItem" > @@ -297,7 +297,7 @@ exports[`Pagination should display the first/last link 1`] = ` class="item pageItem" > @@ -344,7 +344,7 @@ exports[`Pagination should have all buttons disabled if there are no results 1`] class="item pageItem selectedItem" > @@ -382,7 +382,7 @@ exports[`Pagination should render five elements 1`] = ` class="item pageItem selectedItem" > @@ -393,7 +393,7 @@ exports[`Pagination should render five elements 1`] = ` class="item pageItem" > @@ -404,7 +404,7 @@ exports[`Pagination should render five elements 1`] = ` class="item pageItem" > @@ -415,7 +415,7 @@ exports[`Pagination should render five elements 1`] = ` class="item pageItem" > @@ -426,7 +426,7 @@ exports[`Pagination should render five elements 1`] = ` class="item pageItem" > @@ -437,7 +437,7 @@ exports[`Pagination should render five elements 1`] = ` class="item pageItem" > @@ -448,7 +448,7 @@ exports[`Pagination should render five elements 1`] = ` class="item pageItem" > diff --git a/packages/instantsearch.js/src/connectors/infinite-hits/connectInfiniteHits.ts b/packages/instantsearch.js/src/connectors/infinite-hits/connectInfiniteHits.ts index 34e84af390..7d91141aa0 100644 --- a/packages/instantsearch.js/src/connectors/infinite-hits/connectInfiniteHits.ts +++ b/packages/instantsearch.js/src/connectors/infinite-hits/connectInfiniteHits.ts @@ -335,7 +335,11 @@ const connectInfiniteHits: InfiniteHitsConnector = function connectInfiniteHits( { results } ); - if (cachedHits[page] === undefined && !results.__isArtificial) { + if ( + cachedHits[page] === undefined && + !results.__isArtificial && + instantSearchInstance.status === 'idle' + ) { cachedHits[page] = transformedHits; cache.write({ state, hits: cachedHits }); } diff --git a/packages/instantsearch.js/src/lib/InstantSearch.ts b/packages/instantsearch.js/src/lib/InstantSearch.ts index 289805b38d..cc97186d20 100644 --- a/packages/instantsearch.js/src/lib/InstantSearch.ts +++ b/packages/instantsearch.js/src/lib/InstantSearch.ts @@ -481,12 +481,7 @@ See ${createDocumentationLink({ mainHelper.search = () => { this.status = 'loading'; - // @MAJOR: use scheduleRender here - // For now, widgets don't expect to be rendered at the start of `loading`, - // so it would be a breaking change to add an extra render. We don't have - // these guarantees about the render event, thus emitting it once more - // isn't a breaking change. - this.emit('render'); + this.scheduleRender(false); // This solution allows us to keep the exact same API for the users but // under the hood, we have a different implementation. It should be @@ -639,7 +634,7 @@ See ${createDocumentationLink({ }); public scheduleRender = defer((shouldResetStatus: boolean = true) => { - if (!this.mainHelper!.hasPendingRequests()) { + if (!this.mainHelper?.hasPendingRequests()) { clearTimeout(this._searchStalledTimer); this._searchStalledTimer = null; diff --git a/packages/instantsearch.js/src/lib/__tests__/InstantSearch-test.tsx b/packages/instantsearch.js/src/lib/__tests__/InstantSearch-test.tsx index 522d5fa63d..307a81ee0d 100644 --- a/packages/instantsearch.js/src/lib/__tests__/InstantSearch-test.tsx +++ b/packages/instantsearch.js/src/lib/__tests__/InstantSearch-test.tsx @@ -1320,14 +1320,20 @@ describe('scheduleStalledRender', () => { await wait(0); expect(widget.render).toHaveBeenCalledTimes(1); + castToJestMock(widget.render!).mockReset(); // Trigger a new search search.mainHelper!.search(); + // search starts + await wait(0); + expect(widget.render).toHaveBeenCalledTimes(1); + castToJestMock(widget.render!).mockReset(); + // Reaches the delay await wait(search._stalledSearchDelay); - expect(widget.render).toHaveBeenCalledTimes(2); + expect(widget.render).toHaveBeenCalledTimes(1); }); it('deduplicates the calls to the `render` method', async () => { @@ -1352,6 +1358,7 @@ describe('scheduleStalledRender', () => { await wait(0); expect(widget.render).toHaveBeenCalledTimes(1); + castToJestMock(widget.render!).mockClear(); // Trigger multiple searches search.mainHelper!.search(); @@ -1359,10 +1366,17 @@ describe('scheduleStalledRender', () => { search.mainHelper!.search(); search.mainHelper!.search(); + await wait(0); + + // search starts + expect(widget.render).toHaveBeenCalledTimes(1); + castToJestMock(widget.render!).mockClear(); + // Reaches the delay await wait(search._stalledSearchDelay); - expect(widget.render).toHaveBeenCalledTimes(2); + expect(widget.render).toHaveBeenCalledTimes(1); + castToJestMock(widget.render!).mockClear(); }); it('triggers a `render` once the search expires the delay', async () => { @@ -1404,12 +1418,27 @@ describe('scheduleStalledRender', () => { search.mainHelper!.search(); expect(widget.render).toHaveBeenCalledTimes(1); + castToJestMock(widget.render!).mockClear(); + + await wait(0); + + // Widgets render because of the search + expect(widget.render).toHaveBeenCalledTimes(1); + expect(widget.render).toHaveBeenLastCalledWith( + expect.objectContaining({ + searchMetadata: { + isSearchStalled: false, + }, + status: 'loading', + }) + ); + castToJestMock(widget.render!).mockClear(); // The delay is reached await wait(search._stalledSearchDelay); // Widgets render because of the stalled search - expect(widget.render).toHaveBeenCalledTimes(2); + expect(widget.render).toHaveBeenCalledTimes(1); expect(widget.render).toHaveBeenLastCalledWith( expect.objectContaining({ searchMetadata: { @@ -1418,6 +1447,7 @@ describe('scheduleStalledRender', () => { status: 'stalled', }) ); + castToJestMock(widget.render!).mockClear(); // Resolve the `search` searches[1].resolver(); @@ -1426,7 +1456,7 @@ describe('scheduleStalledRender', () => { await wait(0); // Widgets render because of the results - expect(widget.render).toHaveBeenCalledTimes(3); + expect(widget.render).toHaveBeenCalledTimes(1); expect(widget.render).toHaveBeenLastCalledWith( expect.objectContaining({ searchMetadata: { diff --git a/packages/instantsearch.js/src/lib/__tests__/status.test.ts b/packages/instantsearch.js/src/lib/__tests__/status.test.ts index 3a7ead247a..c6f702953e 100644 --- a/packages/instantsearch.js/src/lib/__tests__/status.test.ts +++ b/packages/instantsearch.js/src/lib/__tests__/status.test.ts @@ -185,7 +185,6 @@ describe('status', () => { }); test('lets users render on error with the `render` event', async () => { - // expect.assertions(4); const search = instantsearch({ indexName: 'indexName', searchClient: createSearchClient({ diff --git a/packages/instantsearch.js/src/lib/utils/createSendEventForHits.ts b/packages/instantsearch.js/src/lib/utils/createSendEventForHits.ts index 84d9321e33..9118789d4b 100644 --- a/packages/instantsearch.js/src/lib/utils/createSendEventForHits.ts +++ b/packages/instantsearch.js/src/lib/utils/createSendEventForHits.ts @@ -31,13 +31,13 @@ const buildPayloads = ({ widgetType, methodName, args, - isSearchStalled, + invalidStatus, }: { widgetType: string; index: string; methodName: 'sendEvent' | 'bindEvent'; args: any[]; - isSearchStalled: boolean; + invalidStatus: boolean; }): InsightsEvent[] => { // when there's only one argument, that means it's custom if (args.length === 1 && typeof args[0] === 'object') { @@ -87,7 +87,7 @@ const buildPayloads = ({ ); if (eventType === 'view') { - if (isSearchStalled) { + if (invalidStatus) { return []; } return hitsChunks.map((batch, i) => { @@ -163,7 +163,7 @@ export function createSendEventForHits({ index, methodName: 'sendEvent', args, - isSearchStalled: instantSearchInstance.status === 'stalled', + invalidStatus: instantSearchInstance.status !== 'idle', }); payloads.forEach((payload) => @@ -186,7 +186,7 @@ export function createBindEventForHits({ index, methodName: 'bindEvent', args, - isSearchStalled: false, + invalidStatus: false, }); return payloads.length diff --git a/packages/instantsearch.js/src/widgets/index/__tests__/index-test.ts b/packages/instantsearch.js/src/widgets/index/__tests__/index-test.ts index 52796f6226..046b766f58 100644 --- a/packages/instantsearch.js/src/widgets/index/__tests__/index-test.ts +++ b/packages/instantsearch.js/src/widgets/index/__tests__/index-test.ts @@ -2927,6 +2927,7 @@ See documentation: https://www.algolia.com/doc/api-reference/widgets/index-widge }, ], _state: { + index: 'indexName', disjunctiveFacets: [], disjunctiveFacetsRefinements: {}, facets: [], diff --git a/packages/instantsearch.js/src/widgets/index/index.ts b/packages/instantsearch.js/src/widgets/index/index.ts index 5998862b1d..4f1a59be32 100644 --- a/packages/instantsearch.js/src/widgets/index/index.ts +++ b/packages/instantsearch.js/src/widgets/index/index.ts @@ -230,7 +230,16 @@ const index = (widgetParams: IndexWidgetParams): IndexWidget => { }, getResults() { - return derivedHelper && derivedHelper.lastResults; + if (!derivedHelper?.lastResults) return null; + + // To make the UI optimistic, we patch the state to display to the current + // one instead of the one associated with the latest results. + // This means user-driven UI changes (e.g., checked checkbox) are reflected + // immediately instead of waiting for Algolia to respond, regardless of + // the status of the network request. + derivedHelper.lastResults._state = helper!.state; + + return derivedHelper.lastResults; }, getScopedResults() { diff --git a/packages/instantsearch.js/src/widgets/infinite-hits/__tests__/infinite-hits-integration-test.ts b/packages/instantsearch.js/src/widgets/infinite-hits/__tests__/infinite-hits-integration-test.ts index 66e46dbe16..86fcd6d170 100644 --- a/packages/instantsearch.js/src/widgets/infinite-hits/__tests__/infinite-hits-integration-test.ts +++ b/packages/instantsearch.js/src/widgets/infinite-hits/__tests__/infinite-hits-integration-test.ts @@ -144,33 +144,33 @@ describe('infiniteHits', () => { }); expect(customCache.write).toHaveBeenCalledTimes(2); expect(customCache.write.mock.calls[1][0].hits).toMatchInlineSnapshot(` -{ - "0": [ - { - "__position": 1, - "objectID": "object-id0", - "title": "title 1", - }, - { - "__position": 2, - "objectID": "object-id1", - "title": "title 2", - }, - ], - "1": [ - { - "__position": 3, - "objectID": "object-id0", - "title": "title 3", - }, - { - "__position": 4, - "objectID": "object-id1", - "title": "title 4", - }, - ], -} -`); + { + "0": [ + { + "__position": 1, + "objectID": "object-id0", + "title": "title 1", + }, + { + "__position": 2, + "objectID": "object-id1", + "title": "title 2", + }, + ], + "1": [ + { + "__position": 3, + "objectID": "object-id0", + "title": "title 3", + }, + { + "__position": 4, + "objectID": "object-id1", + "title": "title 4", + }, + ], + } + `); }); it('displays all the hits from cache', async () => { diff --git a/packages/react-instantsearch-core/package.json b/packages/react-instantsearch-core/package.json index 630b5e7f64..121beeda70 100644 --- a/packages/react-instantsearch-core/package.json +++ b/packages/react-instantsearch-core/package.json @@ -38,7 +38,7 @@ }, "dependencies": { "@babel/runtime": "^7.1.2", - "algoliasearch-helper": "^3.11.2", + "algoliasearch-helper": "^3.11.3", "prop-types": "^15.6.2", "react-fast-compare": "^3.0.0" }, diff --git a/packages/react-instantsearch-dom/package.json b/packages/react-instantsearch-dom/package.json index 50102b4aed..0981ab9071 100644 --- a/packages/react-instantsearch-dom/package.json +++ b/packages/react-instantsearch-dom/package.json @@ -41,7 +41,7 @@ }, "dependencies": { "@babel/runtime": "^7.1.2", - "algoliasearch-helper": "^3.11.2", + "algoliasearch-helper": "^3.11.3", "classnames": "^2.2.5", "prop-types": "^15.6.2", "react-fast-compare": "^3.0.0", diff --git a/packages/react-instantsearch-hooks-server/src/__tests__/__snapshots__/getServerState.test.tsx.snap b/packages/react-instantsearch-hooks-server/src/__tests__/__snapshots__/getServerState.test.tsx.snap index 2a8ba11064..5f8c443ed5 100644 --- a/packages/react-instantsearch-hooks-server/src/__tests__/__snapshots__/getServerState.test.tsx.snap +++ b/packages/react-instantsearch-hooks-server/src/__tests__/__snapshots__/getServerState.test.tsx.snap @@ -200,14 +200,8 @@ exports[`getServerState returns initialResults 1`] = ` }, ], "state": { - "disjunctiveFacets": [ - "brand", - ], - "disjunctiveFacetsRefinements": { - "brand": [ - "Apple", - ], - }, + "disjunctiveFacets": [], + "disjunctiveFacetsRefinements": {}, "facets": [], "facetsExcludes": {}, "facetsRefinements": {}, @@ -216,9 +210,7 @@ exports[`getServerState returns initialResults 1`] = ` "highlightPostTag": "__/ais-highlight__", "highlightPreTag": "__ais-highlight__", "index": "instant_search_price_asc", - "maxValuesPerFacet": 10, "numericRefinements": {}, - "query": "iphone", "tagRefinements": [], }, }, @@ -310,14 +302,8 @@ exports[`getServerState returns initialResults 1`] = ` }, ], "state": { - "disjunctiveFacets": [ - "brand", - ], - "disjunctiveFacetsRefinements": { - "brand": [ - "Apple", - ], - }, + "disjunctiveFacets": [], + "disjunctiveFacetsRefinements": {}, "facets": [], "facetsExcludes": {}, "facetsRefinements": {}, @@ -326,9 +312,7 @@ exports[`getServerState returns initialResults 1`] = ` "highlightPostTag": "__/ais-highlight__", "highlightPreTag": "__ais-highlight__", "index": "instant_search_price_desc", - "maxValuesPerFacet": 10, "numericRefinements": {}, - "query": "iphone", "tagRefinements": [], }, }, @@ -420,14 +404,8 @@ exports[`getServerState returns initialResults 1`] = ` }, ], "state": { - "disjunctiveFacets": [ - "brand", - ], - "disjunctiveFacetsRefinements": { - "brand": [ - "Apple", - ], - }, + "disjunctiveFacets": [], + "disjunctiveFacetsRefinements": {}, "facets": [], "facetsExcludes": {}, "facetsRefinements": {}, @@ -436,9 +414,7 @@ exports[`getServerState returns initialResults 1`] = ` "highlightPostTag": "__/ais-highlight__", "highlightPreTag": "__ais-highlight__", "index": "instant_search_rating_desc", - "maxValuesPerFacet": 10, "numericRefinements": {}, - "query": "iphone", "tagRefinements": [], }, }, diff --git a/packages/react-instantsearch-hooks-web/src/__tests__/common.test.tsx b/packages/react-instantsearch-hooks-web/src/__tests__/common.test.tsx new file mode 100644 index 0000000000..64873e25c3 --- /dev/null +++ b/packages/react-instantsearch-hooks-web/src/__tests__/common.test.tsx @@ -0,0 +1,51 @@ +/** + * @jest-environment jsdom + */ +import { + createRefinementListTests, + createHierarchicalMenuTests, + createMenuTests, + createPaginationTests, +} from '@instantsearch/tests'; +import { act, render } from '@testing-library/react'; +import React from 'react'; + +import { + InstantSearch, + RefinementList, + HierarchicalMenu, + Menu, + Pagination, +} from '..'; + +createRefinementListTests(({ instantSearchOptions, widgetParams }) => { + render( + + + + ); +}, act); + +createHierarchicalMenuTests(({ instantSearchOptions, widgetParams }) => { + render( + + + + ); +}, act); + +createMenuTests(({ instantSearchOptions, widgetParams }) => { + render( + + + + ); +}, act); + +createPaginationTests(({ instantSearchOptions, widgetParams }) => { + render( + + + + ); +}, act); diff --git a/packages/react-instantsearch-hooks/package.json b/packages/react-instantsearch-hooks/package.json index 0f5181f079..25e8d116fe 100644 --- a/packages/react-instantsearch-hooks/package.json +++ b/packages/react-instantsearch-hooks/package.json @@ -48,7 +48,7 @@ }, "dependencies": { "@babel/runtime": "^7.1.2", - "algoliasearch-helper": "^3.11.2", + "algoliasearch-helper": "^3.11.3", "instantsearch.js": "^4.49.4", "use-sync-external-store": "^1.0.0" }, diff --git a/packages/react-instantsearch-hooks/src/components/__tests__/InstantSearch.test.tsx b/packages/react-instantsearch-hooks/src/components/__tests__/InstantSearch.test.tsx index 2892652f2b..64e3c31f3a 100644 --- a/packages/react-instantsearch-hooks/src/components/__tests__/InstantSearch.test.tsx +++ b/packages/react-instantsearch-hooks/src/components/__tests__/InstantSearch.test.tsx @@ -427,12 +427,13 @@ describe('InstantSearch', () => { await waitFor(() => { expect(searchClient.search).toHaveBeenCalledTimes(1); + searchClient.search.mockClear(); }); userEvent.type(screen.getByRole('searchbox'), 'iphone'); await waitFor(() => { - expect(searchClient.search).toHaveBeenCalledTimes(2); + expect(searchClient.search).toHaveBeenCalledTimes(1); expect(searchClient.search).toHaveBeenLastCalledWith([ { indexName: 'indexName', @@ -441,6 +442,7 @@ describe('InstantSearch', () => { }), }, ]); + searchClient.search.mockClear(); }); rerender(); @@ -450,7 +452,7 @@ describe('InstantSearch', () => { }); await waitFor(() => { - expect(searchClient.search).toHaveBeenCalledTimes(3); + expect(searchClient.search).toHaveBeenCalledTimes(1); expect(searchClient.search).toHaveBeenLastCalledWith([ { indexName: 'indexName', @@ -490,12 +492,13 @@ describe('InstantSearch', () => { await waitFor(() => { expect(searchClient.search).toHaveBeenCalledTimes(1); + searchClient.search.mockClear(); }); userEvent.type(screen.getByRole('searchbox'), 'iphone'); await waitFor(() => { - expect(searchClient.search).toHaveBeenCalledTimes(2); + expect(searchClient.search).toHaveBeenCalledTimes(1); expect(searchClient.search).toHaveBeenLastCalledWith([ { indexName: 'indexName', @@ -504,6 +507,7 @@ describe('InstantSearch', () => { }), }, ]); + searchClient.search.mockClear(); }); rerender(); @@ -513,7 +517,7 @@ describe('InstantSearch', () => { }); await waitFor(() => { - expect(searchClient.search).toHaveBeenCalledTimes(3); + expect(searchClient.search).toHaveBeenCalledTimes(1); expect(searchClient.search).toHaveBeenLastCalledWith([ { indexName: 'indexName', @@ -619,33 +623,36 @@ describe('InstantSearch', () => { }); expect(searchClient.search).toHaveBeenCalledTimes(1); + searchClient.search.mockClear(); rerender(); await waitFor(() => { - expect(searchClient.search).toHaveBeenCalledTimes(2); + expect(searchClient.search).toHaveBeenCalledTimes(1); expect(searchClient.search).toHaveBeenLastCalledWith([ expect.objectContaining({ indexName: 'indexName2', }), ]); + searchClient.search.mockClear(); }); rerender(); await waitFor(() => { - expect(searchClient.search).toHaveBeenCalledTimes(3); + expect(searchClient.search).toHaveBeenCalledTimes(1); expect(searchClient.search).toHaveBeenLastCalledWith([ expect.objectContaining({ indexName: 'indexName3', }), ]); + searchClient.search.mockClear(); }); userEvent.type(screen.getByRole('searchbox'), 'iphone'); await waitFor(() => { - expect(searchClient.search).toHaveBeenCalledTimes(9); + expect(searchClient.search).toHaveBeenCalledTimes(6); expect(searchClient.search).toHaveBeenLastCalledWith([ { indexName: 'indexName3', @@ -654,6 +661,7 @@ describe('InstantSearch', () => { }), }, ]); + searchClient.search.mockClear(); }); }); diff --git a/packages/react-instantsearch-hooks/src/components/__tests__/InstantSearchSSRProvider.test.tsx b/packages/react-instantsearch-hooks/src/components/__tests__/InstantSearchSSRProvider.test.tsx index 446988c69e..bbdb810237 100644 --- a/packages/react-instantsearch-hooks/src/components/__tests__/InstantSearchSSRProvider.test.tsx +++ b/packages/react-instantsearch-hooks/src/components/__tests__/InstantSearchSSRProvider.test.tsx @@ -9,7 +9,11 @@ import { simple } from 'instantsearch.js/es/lib/stateMappings'; import React, { StrictMode } from 'react'; import { Hits, RefinementList, SearchBox } from 'react-instantsearch-hooks-web'; -import { createSearchClient } from '../../../../../tests/mock'; +import { + createMultiSearchResponse, + createSearchClient, + createSingleSearchResponse, +} from '../../../../../tests/mock'; import { InstantSearch } from '../InstantSearch'; import { InstantSearchSSRProvider } from '../InstantSearchSSRProvider'; @@ -190,8 +194,25 @@ describe('InstantSearchSSRProvider', () => { }); }); - test('renders refinements from initial results state', async () => { - const searchClient = createSearchClient({}); + test('renders refinements from local widget state', async () => { + const searchClient = createSearchClient({ + search: jest.fn((requests) => { + return Promise.resolve( + createMultiSearchResponse( + ...requests.map(() => + createSingleSearchResponse({ + facets: { + brand: { + Samsung: 633, + Apple: 442, + }, + }, + }) + ) + ) + ); + }), + }); const initialResults = { indexName: { state: { @@ -200,6 +221,7 @@ describe('InstantSearchSSRProvider', () => { hierarchicalFacets: [], facetsRefinements: {}, facetsExcludes: {}, + // gets ignored, the value is taken from the local widget state (which is not checked) disjunctiveFacetsRefinements: { brand: ['Apple'] }, numericRefinements: {}, tagRefinements: [], @@ -272,13 +294,20 @@ describe('InstantSearchSSRProvider', () => { ); } - render(); + const { getByRole } = render(); await waitFor(() => { - expect(screen.getByRole('checkbox', { name: 'Apple 442' })).toBeChecked(); - expect( - screen.getByRole('checkbox', { name: 'Samsung 633' }) - ).not.toBeChecked(); + expect(searchClient.search).toHaveBeenCalledTimes(0); + expect(getByRole('checkbox', { name: 'Apple 442' })).not.toBeChecked(); + expect(getByRole('checkbox', { name: 'Samsung 633' })).not.toBeChecked(); + }); + + userEvent.click(getByRole('checkbox', { name: 'Apple 442' })); + + await waitFor(() => { + expect(searchClient.search).toHaveBeenCalledTimes(1); + expect(getByRole('checkbox', { name: 'Apple 442' })).toBeChecked(); + expect(getByRole('checkbox', { name: 'Samsung 633' })).not.toBeChecked(); }); }); @@ -437,112 +466,4 @@ describe('InstantSearchSSRProvider', () => { ]); }); }); - - // Fixes https://github.com/algolia/react-instantsearch/issues/3530 - test('renders initial refinement and allows to refine them', async () => { - const searchClient = createSearchClient({}); - const initialResults = { - indexName: { - state: { - facets: [], - disjunctiveFacets: ['brand'], - hierarchicalFacets: [], - facetsRefinements: {}, - facetsExcludes: {}, - disjunctiveFacetsRefinements: { brand: ['Apple'] }, - numericRefinements: {}, - tagRefinements: [], - hierarchicalFacetsRefinements: {}, - index: 'indexName', - query: '', - }, - results: [ - { - hits: [ - { - name: 'Apple - MacBook Air® (Latest Model) - 13.3" Display - Intel Core i5 - 8GB Memory - 128GB Flash Storage - Silver', - objectID: '6443034', - }, - { - name: 'Apple - EarPods™ with Remote and Mic - White', - objectID: '6848136', - }, - ], - nbHits: 442, - page: 0, - nbPages: 23, - hitsPerPage: 2, - facets: { brand: { Apple: 442, Samsung: 633 } }, - exhaustiveFacetsCount: true, - exhaustiveNbHits: true, - exhaustiveTypo: true, - query: '', - queryAfterRemoval: '', - params: '', - index: 'indexName', - processingTimeMS: 1, - }, - { - hits: [ - { - name: 'Amazon - Fire TV Stick with Alexa Voice Remote - Black', - objectID: '5477500', - }, - ], - nbHits: 21469, - page: 0, - nbPages: 1000, - hitsPerPage: 1, - facets: { - brand: { Samsung: 633, Apple: 442 }, - }, - exhaustiveFacetsCount: true, - exhaustiveNbHits: true, - exhaustiveTypo: true, - query: '', - queryAfterRemoval: '', - params: '', - index: 'indexName', - processingTimeMS: 1, - }, - ], - }, - }; - const routing = { - stateMapping: simple(), - router: history({ - getLocation() { - return new URL( - `http://localhost/?indexName[query]=iphone` - ) as unknown as Location; - }, - }), - }; - - function App() { - return ( - - - - - - - - - ); - } - - const { getByRole } = render(); - const appleRefinement = getByRole('checkbox', { name: 'Apple 442' }); - - userEvent.click(appleRefinement); - - await waitFor(() => { - expect(appleRefinement).toBeChecked(); - }); - }); }); diff --git a/packages/react-instantsearch-hooks/src/hooks/__tests__/useInstantSearch.test.tsx b/packages/react-instantsearch-hooks/src/hooks/__tests__/useInstantSearch.test.tsx index a7233bdb84..0bbf90e7c6 100644 --- a/packages/react-instantsearch-hooks/src/hooks/__tests__/useInstantSearch.test.tsx +++ b/packages/react-instantsearch-hooks/src/hooks/__tests__/useInstantSearch.test.tsx @@ -365,9 +365,10 @@ describe('useInstantSearch', () => { test('turns to loading and error when searching', async () => { const searchClient = createSearchClient({}); - searchClient.search.mockImplementation(() => - Promise.reject(new Error('API_ERROR')) - ); + searchClient.search.mockImplementation(async () => { + await wait(100); + throw new Error('API_ERROR'); + }); const App = () => ( diff --git a/packages/vue-instantsearch/package.json b/packages/vue-instantsearch/package.json index 36d06bc3f8..6fa51b1f8e 100644 --- a/packages/vue-instantsearch/package.json +++ b/packages/vue-instantsearch/package.json @@ -60,7 +60,7 @@ "@vue/test-utils": "1.3.0", "@vue/test-utils2": "npm:@vue/test-utils@2.0.0-rc.11", "algoliasearch": "4.14.3", - "algoliasearch-helper": "3.11.2", + "algoliasearch-helper": "3.11.3", "instantsearch.css": "8.0.0", "rollup": "1.32.1", "rollup-plugin-babel": "4.4.0", diff --git a/packages/vue-instantsearch/src/__tests__/common.test.js b/packages/vue-instantsearch/src/__tests__/common.test.js new file mode 100644 index 0000000000..1567c3fb9f --- /dev/null +++ b/packages/vue-instantsearch/src/__tests__/common.test.js @@ -0,0 +1,80 @@ +/** + * @jest-environment jsdom + */ +import { + createRefinementListTests, + createHierarchicalMenuTests, + createMenuTests, + createPaginationTests, +} from '@instantsearch/tests'; + +import { nextTick, mountApp } from '../../test/utils'; +import { renderCompat } from '../util/vue-compat'; +import { + AisInstantSearch, + AisRefinementList, + AisHierarchicalMenu, + AisMenu, + AisPagination, +} from '../instantsearch'; +jest.unmock('instantsearch.js/es'); + +createRefinementListTests(async ({ instantSearchOptions, widgetParams }) => { + mountApp( + { + render: renderCompat((h) => + h(AisInstantSearch, { props: instantSearchOptions }, [ + h(AisRefinementList, { props: widgetParams }), + ]) + ), + }, + document.body.appendChild(document.createElement('div')) + ); + + await nextTick(); +}); + +createHierarchicalMenuTests(async ({ instantSearchOptions, widgetParams }) => { + mountApp( + { + render: renderCompat((h) => + h(AisInstantSearch, { props: instantSearchOptions }, [ + h(AisHierarchicalMenu, { props: widgetParams }), + ]) + ), + }, + document.body.appendChild(document.createElement('div')) + ); + + await nextTick(); +}); + +createMenuTests(async ({ instantSearchOptions, widgetParams }) => { + mountApp( + { + render: renderCompat((h) => + h(AisInstantSearch, { props: instantSearchOptions }, [ + h(AisMenu, { props: widgetParams }), + ]) + ), + }, + document.body.appendChild(document.createElement('div')) + ); + + await nextTick(); +}); + +createPaginationTests(async ({ instantSearchOptions, widgetParams }) => { + mountApp( + { + render: renderCompat((h) => + h(AisInstantSearch, { props: instantSearchOptions }, [ + h(AisPagination, { props: widgetParams }), + ]) + ), + }, + document.body.appendChild(document.createElement('div')) + ); + + await nextTick(); +}); diff --git a/packages/vue-instantsearch/src/components/Pagination.vue b/packages/vue-instantsearch/src/components/Pagination.vue index c06f5ae14a..a3adc49c8c 100644 --- a/packages/vue-instantsearch/src/components/Pagination.vue +++ b/packages/vue-instantsearch/src/components/Pagination.vue @@ -88,6 +88,7 @@ {{ page + 1 }} diff --git a/packages/vue-instantsearch/src/components/__tests__/__snapshots__/Pagination.js.snap b/packages/vue-instantsearch/src/components/__tests__/__snapshots__/Pagination.js.snap index 662539806a..624de3cd83 100644 --- a/packages/vue-instantsearch/src/components/__tests__/__snapshots__/Pagination.js.snap +++ b/packages/vue-instantsearch/src/components/__tests__/__snapshots__/Pagination.js.snap @@ -20,49 +20,56 @@ exports[`renders correctly another page 1`] = `
  • - 4
  • - 5
  • - 6
  • - 7
  • - 8
  • - 9
  • - 10 @@ -106,49 +113,56 @@ exports[`renders correctly first page 1`] = `
  • - 1
  • - 2
  • - 3
  • - 4
  • - 5
  • - 6
  • - 7 @@ -194,49 +208,56 @@ exports[`renders correctly last page 1`] = `
  • - 4
  • - 5
  • - 6
  • - 7
  • - 8
  • - 9
  • - 10 diff --git a/packages/vue-instantsearch/src/util/__tests__/createServerRootMixin.test.js b/packages/vue-instantsearch/src/util/__tests__/createServerRootMixin.test.js index 5fb4de395a..57178ab363 100644 --- a/packages/vue-instantsearch/src/util/__tests__/createServerRootMixin.test.js +++ b/packages/vue-instantsearch/src/util/__tests__/createServerRootMixin.test.js @@ -1133,6 +1133,7 @@ See documentation: https://www.algolia.com/doc/api-reference/widgets/instantsear const resultsState = createSerializedState(); const state = new SearchParameters(resultsState.state); + const localState = new SearchParameters({ index: 'lol' }); const results = new SearchResults(state, resultsState.results); instantSearchInstance.hydrate({ @@ -1149,18 +1150,13 @@ See documentation: https://www.algolia.com/doc/api-reference/widgets/instantsear const renderArgs = widget.render.mock.calls[0][0]; - expect(renderArgs).toEqual( - expect.objectContaining({ - state, - results, - scopedResults: [ - expect.objectContaining({ - indexId: 'lol', - results, - }), - ], - }) - ); + // renders with local state, not the one from results + expect(renderArgs.state).toEqual(localState); + results._state = localState; + expect(renderArgs.results).toEqual(results); + expect(renderArgs.scopedResults).toHaveLength(1); + expect(renderArgs.scopedResults[0].indexId).toEqual('lol'); + expect(renderArgs.scopedResults[0].results).toEqual(results); }); describe('createURL', () => { diff --git a/packages/vue-instantsearch/test/utils/index.js b/packages/vue-instantsearch/test/utils/index.js index 5452e01318..642a68f512 100644 --- a/packages/vue-instantsearch/test/utils/index.js +++ b/packages/vue-instantsearch/test/utils/index.js @@ -74,4 +74,12 @@ export const createSSRApp = (props) => { } }; +export const mountApp = (props, container) => { + if (isVue3) { + return _createApp(props).mount(container); + } else { + return new Vue2(props).$mount(container); + } +}; + export const nextTick = () => (isVue3 ? _nextTick() : Vue2.nextTick()); diff --git a/tests/common/.eslintrc b/tests/common/.eslintrc new file mode 100644 index 0000000000..f2485fca9a --- /dev/null +++ b/tests/common/.eslintrc @@ -0,0 +1,6 @@ +{ + "rules": { + "jest/no-export": "off", + "no-lone-blocks": "off", + } +} diff --git a/tests/common/common.ts b/tests/common/common.ts new file mode 100644 index 0000000000..26edb17f1c --- /dev/null +++ b/tests/common/common.ts @@ -0,0 +1,17 @@ +import type { InstantSearchOptions } from 'instantsearch.js'; + +type MaybePromise = Promise | TResolution; + +type TestSetupOptions = { + instantSearchOptions: InstantSearchOptions; +}; + +export type TestSetup> = ( + options: TestSetupOptions & TOptions +) => MaybePromise; + +export interface Act { + (callback: () => Promise): Promise; + (callback: () => void): void; +} +export const fakeAct = ((cb) => cb()) as Act; diff --git a/tests/common/index.ts b/tests/common/index.ts new file mode 100644 index 0000000000..2531ea4ac3 --- /dev/null +++ b/tests/common/index.ts @@ -0,0 +1,4 @@ +export * from './widgets/hierarchical-menu'; +export * from './widgets/refinement-list'; +export * from './widgets/menu'; +export * from './widgets/pagination'; diff --git a/tests/common/package.json b/tests/common/package.json new file mode 100644 index 0000000000..902b8d1e62 --- /dev/null +++ b/tests/common/package.json @@ -0,0 +1,8 @@ +{ + "name": "@instantsearch/tests", + "version": "1.0.3", + "description": "Common tests for all InstantSearch flavors.", + "dependencies": { + "instantsearch.js": "4.49.4" + } +} diff --git a/tests/common/widgets/hierarchical-menu/index.ts b/tests/common/widgets/hierarchical-menu/index.ts new file mode 100644 index 0000000000..b8f34e5db6 --- /dev/null +++ b/tests/common/widgets/hierarchical-menu/index.ts @@ -0,0 +1,22 @@ +import type { HierarchicalMenuWidget } from 'instantsearch.js/es/widgets/hierarchical-menu/hierarchical-menu'; +import type { Act, TestSetup } from '../../common'; +import { fakeAct } from '../../common'; +import { createOptimisticUiTests } from './optimistic-ui'; + +type WidgetParams = Parameters[0]; +export type HierarchicalMenuSetup = TestSetup<{ + widgetParams: Omit; +}>; + +export function createHierarchicalMenuTests( + setup: HierarchicalMenuSetup, + act: Act = fakeAct +) { + beforeEach(() => { + document.body.innerHTML = ''; + }); + + describe('HierarchicalMenu common tests', () => { + createOptimisticUiTests(setup, act); + }); +} diff --git a/tests/common/widgets/hierarchical-menu/optimistic-ui.ts b/tests/common/widgets/hierarchical-menu/optimistic-ui.ts new file mode 100644 index 0000000000..11032204af --- /dev/null +++ b/tests/common/widgets/hierarchical-menu/optimistic-ui.ts @@ -0,0 +1,122 @@ +import { wait } from '@instantsearch/testutils'; +import { + createSearchClient, + createMultiSearchResponse, + createSingleSearchResponse, +} from '@instantsearch/mocks'; +import type { HierarchicalMenuSetup } from '.'; +import type { Act } from '../../common'; +import userEvent from '@testing-library/user-event'; +import { screen } from '@testing-library/dom'; + +export function createOptimisticUiTests( + setup: HierarchicalMenuSetup, + act: Act +) { + describe('optimistic UI', () => { + test('checks the clicked refinement immediately regardless of network latency', async () => { + const delay = 100; + const margin = 10; + const attributes = ['brand']; + const options = { + instantSearchOptions: { + indexName: 'indexName', + searchClient: createSearchClient({ + search: jest.fn(async (requests) => { + await wait(delay); + return createMultiSearchResponse( + ...requests.map(() => + createSingleSearchResponse({ + facets: { + [attributes[0]]: { + Samsung: 100, + Apple: 200, + }, + }, + }) + ) + ); + }), + }), + }, + widgetParams: { attributes }, + }; + + await setup(options); + + // Wait for initial results to populate widgets with data + await act(async () => { + await wait(margin + delay); + await wait(0); + }); + + // Initial state, before interaction + { + expect( + document.querySelectorAll('.ais-HierarchicalMenu-item') + ).toHaveLength(2); + expect( + document.querySelectorAll('.ais-HierarchicalMenu-item--selected') + ).toHaveLength(0); + } + + // Select a refinement + { + const firstItem = screen.getByRole('link', { + name: 'Apple 200', + }); + await act(async () => { + userEvent.click(firstItem); + await wait(0); + await wait(0); + }); + + // UI has changed immediately after the user interaction + // @TODO: menu doesn't have any accessible way to determine if an item is selected, so we use the class name (https://github.com/algolia/instantsearch/issues/5187) + expect( + document.querySelectorAll('.ais-HierarchicalMenu-item--selected') + ).toHaveLength(1); + } + + // Wait for new results to come in + { + await act(async () => { + await wait(delay + margin); + }); + + expect( + document.querySelectorAll('.ais-HierarchicalMenu-item--selected') + ).toHaveLength(1); + } + + // Unselect a refinement + { + const firstItem = screen.getByRole('link', { + name: 'Apple 200', + }); + await act(async () => { + userEvent.click(firstItem); + await wait(0); + await wait(0); + }); + + // UI has changed immediately after the user interaction + expect( + document.querySelectorAll('.ais-HierarchicalMenu-item--selected') + ).toHaveLength(0); + } + + // Wait for new results to come in + { + await act(async () => { + await wait(delay + margin); + await wait(0); + }); + + expect( + document.querySelectorAll('.ais-HierarchicalMenu-item--selected') + ).toHaveLength(0); + } + }); + }); +} diff --git a/tests/common/widgets/menu/index.ts b/tests/common/widgets/menu/index.ts new file mode 100644 index 0000000000..3df854b115 --- /dev/null +++ b/tests/common/widgets/menu/index.ts @@ -0,0 +1,19 @@ +import type { MenuWidget } from 'instantsearch.js/es/widgets/menu/menu'; +import type { Act, TestSetup } from '../../common'; +import { fakeAct } from '../../common'; +import { createOptimisticUiTests } from './optimistic-ui'; + +type WidgetParams = Parameters[0]; +export type MenuSetup = TestSetup<{ + widgetParams: Omit; +}>; + +export function createMenuTests(setup: MenuSetup, act: Act = fakeAct) { + beforeEach(() => { + document.body.innerHTML = ''; + }); + + describe('Menu common tests', () => { + createOptimisticUiTests(setup, act); + }); +} diff --git a/tests/common/widgets/menu/optimistic-ui.ts b/tests/common/widgets/menu/optimistic-ui.ts new file mode 100644 index 0000000000..29ccc5299f --- /dev/null +++ b/tests/common/widgets/menu/optimistic-ui.ts @@ -0,0 +1,119 @@ +import { wait } from '@instantsearch/testutils'; +import { + createSearchClient, + createMultiSearchResponse, + createSingleSearchResponse, +} from '@instantsearch/mocks'; +import type { MenuSetup } from '.'; +import type { Act } from '../../common'; +import userEvent from '@testing-library/user-event'; +import { screen } from '@testing-library/dom'; + +export function createOptimisticUiTests(setup: MenuSetup, act: Act) { + describe('optimistic UI', () => { + test('checks the clicked refinement immediately regardless of network latency', async () => { + const delay = 100; + const margin = 10; + const attribute = 'brand'; + const options = { + instantSearchOptions: { + indexName: 'indexName', + searchClient: createSearchClient({ + search: jest.fn(async (requests) => { + await wait(delay); + return createMultiSearchResponse( + ...requests.map(() => + createSingleSearchResponse({ + facets: { + [attribute]: { + Samsung: 100, + Apple: 200, + }, + }, + }) + ) + ); + }), + }), + }, + widgetParams: { attribute }, + }; + + await setup(options); + + // Wait for initial results to populate widgets with data + await act(async () => { + await wait(margin + delay); + await wait(0); + }); + + // Initial state, before interaction + { + expect(document.querySelectorAll('.ais-Menu-item')).toHaveLength(2); + expect( + document.querySelectorAll('.ais-Menu-item--selected') + ).toHaveLength(0); + } + + // Select a refinement + { + const firstItem = screen.getByRole('link', { + name: 'Apple 200', + }); + + await act(async () => { + userEvent.click(firstItem); + await wait(0); + await wait(0); + }); + + // UI has changed immediately after the user interaction + // @TODO: menu doesn't have any accessible way to determine if an item is selected, so we use the class name (https://github.com/algolia/instantsearch/issues/5187) + expect( + document.querySelectorAll('.ais-Menu-item--selected') + ).toHaveLength(1); + } + + // Wait for new results to come in + { + await act(async () => { + await wait(delay + margin); + }); + + expect( + document.querySelectorAll('.ais-Menu-item--selected') + ).toHaveLength(1); + } + + // Unselect the refinement + { + const firstItem = screen.getByRole('link', { + name: 'Apple 200', + }); + + await act(async () => { + userEvent.click(firstItem); + await wait(0); + await wait(0); + }); + + // UI has changed immediately after the user interaction + expect( + document.querySelectorAll('.ais-Menu-item--selected') + ).toHaveLength(0); + } + + // Wait for new results to come in + { + await act(async () => { + await wait(delay + margin); + await wait(0); + }); + + expect( + document.querySelectorAll('.ais-Menu-item--selected') + ).toHaveLength(0); + } + }); + }); +} diff --git a/tests/common/widgets/pagination/index.ts b/tests/common/widgets/pagination/index.ts new file mode 100644 index 0000000000..34b11020d9 --- /dev/null +++ b/tests/common/widgets/pagination/index.ts @@ -0,0 +1,22 @@ +import type { PaginationWidget } from 'instantsearch.js/es/widgets/pagination/pagination'; +import type { Act, TestSetup } from '../../common'; +import { fakeAct } from '../../common'; +import { createOptimisticUiTests } from './optimistic-ui'; + +type WidgetParams = Parameters[0]; +export type PaginationSetup = TestSetup<{ + widgetParams: Omit; +}>; + +export function createPaginationTests( + setup: PaginationSetup, + act: Act = fakeAct +) { + beforeAll(() => { + document.body.innerHTML = ''; + }); + + describe('Pagination common tests', () => { + createOptimisticUiTests(setup, act); + }); +} diff --git a/tests/common/widgets/pagination/optimistic-ui.ts b/tests/common/widgets/pagination/optimistic-ui.ts new file mode 100644 index 0000000000..203da0a7ca --- /dev/null +++ b/tests/common/widgets/pagination/optimistic-ui.ts @@ -0,0 +1,134 @@ +import { wait } from '@instantsearch/testutils'; +import { screen } from '@testing-library/dom'; +import { + createSearchClient, + createMultiSearchResponse, + createSingleSearchResponse, +} from '@instantsearch/mocks'; +import type { PaginationSetup } from '.'; +import type { Act } from '../../common'; + +export function createOptimisticUiTests(setup: PaginationSetup, act: Act) { + // https://github.com/jsdom/jsdom/issues/1695 + window.Element.prototype.scrollIntoView = jest.fn(); + + describe('optimistic UI', () => { + test('checks the clicked refinement immediately regardless of network latency', async () => { + const delay = 100; + const margin = 10; + const options = { + instantSearchOptions: { + indexName: 'indexName', + searchClient: createSearchClient({ + search: jest.fn(async (requests) => { + await wait(delay); + return createMultiSearchResponse( + ...requests.map(({ params }) => + createSingleSearchResponse({ + page: params!.page, + nbPages: 20, + }) + ) + ); + }), + }), + }, + widgetParams: {}, + }; + + await setup(options); + + // Wait for initial results to populate widgets with data + await act(async () => { + await wait(margin + delay); + await wait(0); + }); + + // Initial state, before interaction + { + const firstPrev = 2; + const nextLast = 2; + const current = 1; + const padding = 2 * 3; + expect(document.querySelectorAll('.ais-Pagination-item')).toHaveLength( + firstPrev + current + padding + nextLast + ); + expect( + document.querySelectorAll('.ais-Pagination-item--selected') + ).toHaveLength(1); + expect( + document.querySelector('.ais-Pagination-item--selected') + ).toHaveTextContent('1'); + } + + // Select a refinement + { + const secondPage = screen.getByRole('link', { name: 'Page 2' }); + await act(async () => { + secondPage.click(); + await wait(0); + await wait(0); + }); + + // UI has changed immediately after the user interaction + expect(secondPage.parentNode).toHaveClass( + 'ais-Pagination-item--selected' + ); + expect( + document.querySelectorAll('.ais-Pagination-item--selected') + ).toHaveLength(1); + } + + // Wait for new results to come in + { + const secondPage = screen.getByRole('link', { name: 'Page 2' }); + + await act(async () => { + await wait(delay + margin); + }); + + expect(secondPage.parentNode).toHaveClass( + 'ais-Pagination-item--selected' + ); + expect( + document.querySelectorAll('.ais-Pagination-item--selected') + ).toHaveLength(1); + } + + // Select a refinement + { + const firstPage = screen.getByRole('link', { name: 'Page 1' }); + + await act(async () => { + firstPage.click(); + await wait(0); + await wait(0); + }); + + // UI has changed immediately after the user interaction + expect(firstPage.parentNode).toHaveClass( + 'ais-Pagination-item--selected' + ); + expect( + document.querySelectorAll('.ais-Pagination-item--selected') + ).toHaveLength(1); + } + + // Wait for new results to come in + { + const firstPage = screen.getByRole('link', { name: 'Page 1' }); + + await act(async () => { + await wait(delay + margin); + }); + + expect(firstPage.parentNode).toHaveClass( + 'ais-Pagination-item--selected' + ); + expect( + document.querySelectorAll('.ais-Pagination-item--selected') + ).toHaveLength(1); + } + }); + }); +} diff --git a/tests/common/widgets/refinement-list/index.ts b/tests/common/widgets/refinement-list/index.ts new file mode 100644 index 0000000000..ad807fbced --- /dev/null +++ b/tests/common/widgets/refinement-list/index.ts @@ -0,0 +1,22 @@ +import type { RefinementListWidget } from 'instantsearch.js/es/widgets/refinement-list/refinement-list'; +import type { TestSetup, Act } from '../../common'; +import { fakeAct } from '../../common'; +import { createOptimisticUiTests } from './optimistic-ui'; + +type WidgetParams = Parameters[0]; +export type RefinementListSetup = TestSetup<{ + widgetParams: Omit; +}>; + +export function createRefinementListTests( + setup: RefinementListSetup, + act: Act = fakeAct +) { + beforeEach(() => { + document.body.innerHTML = ''; + }); + + describe('RefinementList common tests', () => { + createOptimisticUiTests(setup, act); + }); +} diff --git a/tests/common/widgets/refinement-list/optimistic-ui.ts b/tests/common/widgets/refinement-list/optimistic-ui.ts new file mode 100644 index 0000000000..5edaa54ff2 --- /dev/null +++ b/tests/common/widgets/refinement-list/optimistic-ui.ts @@ -0,0 +1,129 @@ +import { wait } from '@instantsearch/testutils'; +import { + createSearchClient, + createMultiSearchResponse, + createSingleSearchResponse, +} from '@instantsearch/mocks'; +import { screen } from '@testing-library/dom'; +import type { RefinementListSetup } from '.'; +import type { Act } from '../../common'; + +export function createOptimisticUiTests(setup: RefinementListSetup, act: Act) { + describe('optimistic UI', () => { + test('checks the clicked refinement immediately regardless of network latency', async () => { + const delay = 100; + const margin = 10; + const attribute = 'brand'; + const options = { + instantSearchOptions: { + indexName: 'indexName', + searchClient: createSearchClient({ + search: jest.fn(async (requests) => { + await wait(delay); + return createMultiSearchResponse( + ...requests.map(() => + createSingleSearchResponse({ + facets: { + [attribute]: { + Samsung: 100, + Apple: 200, + }, + }, + }) + ) + ); + }), + }), + }, + widgetParams: { attribute }, + }; + + await setup(options); + + // Wait for initial results to populate widgets with data + await act(async () => { + await wait(margin + delay); + await wait(0); + }); + + // Initial state, before interaction + { + expect( + document.querySelectorAll('.ais-RefinementList-item') + ).toHaveLength(2); + expect( + document.querySelectorAll('.ais-RefinementList-item--selected') + ).toHaveLength(0); + } + + // Select a refinement + { + const firstItem = screen.getByRole('checkbox', { + name: 'Apple 200', + }); + await act(async () => { + firstItem.click(); + await wait(0); + await wait(0); + }); + + // UI has changed immediately after the user interaction + expect(firstItem).toBeChecked(); + expect( + document.querySelectorAll('.ais-RefinementList-item--selected') + ).toHaveLength(1); + } + + // Wait for new results to come in + { + const firstItem = screen.getByRole('checkbox', { + name: 'Apple 200', + }); + + await act(async () => { + await wait(delay + margin); + }); + + expect(firstItem).toBeChecked(); + expect( + document.querySelectorAll('.ais-RefinementList-item--selected') + ).toHaveLength(1); + } + + // Unselect the refinement + { + const firstItem = screen.getByRole('checkbox', { + name: 'Apple 200', + }); + await act(async () => { + firstItem.click(); + await wait(0); + await wait(0); + }); + + // UI has changed immediately after the user interaction + expect(firstItem).not.toBeChecked(); + expect( + document.querySelectorAll('.ais-RefinementList-item--selected') + ).toHaveLength(0); + } + + // Wait for new results to come in + { + const firstItem = screen.getByRole('checkbox', { + name: 'Apple 200', + }); + + await act(async () => { + await wait(delay + margin); + await wait(0); + }); + + expect(firstItem).not.toBeChecked(); + expect( + document.querySelectorAll('.ais-RefinementList-item--selected') + ).toHaveLength(0); + } + }); + }); +} diff --git a/tests/mocks/createSearchClient.ts b/tests/mocks/createSearchClient.ts index f97e88fc18..dd06b576d6 100644 --- a/tests/mocks/createSearchClient.ts +++ b/tests/mocks/createSearchClient.ts @@ -29,14 +29,18 @@ type ControlledClient = { }; export const createControlledSearchClient = ( - args: Partial = {} + args: Partial = {}, + createResponse = (...params: Parameters) => + createMultiSearchResponse( + ...params[0].map(() => createSingleSearchResponse()) + ) ): ControlledClient => { const searches: ControlledClient['searches'] = []; const searchClient = createSearchClient({ - search: jest.fn(() => { + search: jest.fn((...params) => { let resolver: () => void; const promise: Promise> = new Promise((resolve) => { - resolver = () => resolve(createMultiSearchResponse()); + resolver = () => resolve(createResponse(...params)); }); searches.push({ diff --git a/tests/mocks/index.ts b/tests/mocks/index.ts new file mode 100644 index 0000000000..4660f7ba17 --- /dev/null +++ b/tests/mocks/index.ts @@ -0,0 +1,3 @@ +export * from './createAPIResponse'; +export * from './createInsightsClient'; +export * from './createSearchClient'; diff --git a/tests/mocks/package.json b/tests/mocks/package.json index 9c3f4c2dfe..ed4e787446 100644 --- a/tests/mocks/package.json +++ b/tests/mocks/package.json @@ -3,7 +3,7 @@ "version": "1.0.3", "private": true, "dependencies": { - "algoliasearch-helper": "3.11.2", + "algoliasearch-helper": "3.11.3", "instantsearch.js": "4.49.4" } } diff --git a/yarn.lock b/yarn.lock index 13a6a09acd..825cef56f9 100644 --- a/yarn.lock +++ b/yarn.lock @@ -8516,10 +8516,10 @@ ajv@^8.0.1: require-from-string "^2.0.2" uri-js "^4.2.2" -algoliasearch-helper@3.11.2, algoliasearch-helper@^3.11.2: - version "3.11.2" - resolved "https://registry.yarnpkg.com/algoliasearch-helper/-/algoliasearch-helper-3.11.2.tgz#f42db10433e6264f1d1ba503699cbdbff7b48dff" - integrity sha512-eKvSM5hz5w9RcUowu8LnQ5v0KRrFLCvF4K3KF/Ab3VwCT726rWgZUWUIQUPjr9qDENUMukQ/IHZ7bGUVYRGP0g== +algoliasearch-helper@3.11.3, algoliasearch-helper@^3.11.3: + version "3.11.3" + resolved "https://registry.yarnpkg.com/algoliasearch-helper/-/algoliasearch-helper-3.11.3.tgz#6e7af8afe6f9a9e55186abffb7b6cf7ca8de3301" + integrity sha512-TbaEvLwiuGygHQIB8y+OsJKQQ40+JKUua5B91X66tMUHyyhbNHvqyr0lqd3wCoyKx7WybyQrC0WJvzoIeh24Aw== dependencies: "@algolia/events" "^4.0.1"