diff --git a/examples/js/e-commerce-umd/polyfills.js b/examples/js/e-commerce-umd/polyfills.js index 4cb5686a0c..1dbbec8f34 100644 --- a/examples/js/e-commerce-umd/polyfills.js +++ b/examples/js/e-commerce-umd/polyfills.js @@ -1,6 +1,6 @@ /* * Polyfill service v3.111.0 - * For detailed credits and licence information see https://polyfill.io. + * For detailed credits and license information see https://polyfill.io. * * Features requested: Array.prototype.find,Array.prototype.findIndex,Array.prototype.includes,Object.assign,Object.entries,Promise,default * diff --git a/examples/js/e-commerce/polyfills.js b/examples/js/e-commerce/polyfills.js index 4cb5686a0c..1dbbec8f34 100644 --- a/examples/js/e-commerce/polyfills.js +++ b/examples/js/e-commerce/polyfills.js @@ -1,6 +1,6 @@ /* * Polyfill service v3.111.0 - * For detailed credits and licence information see https://polyfill.io. + * For detailed credits and license information see https://polyfill.io. * * Features requested: Array.prototype.find,Array.prototype.findIndex,Array.prototype.includes,Object.assign,Object.entries,Promise,default * diff --git a/packages/instantsearch-core/src/__tests__/RoutingManager.test.ts b/packages/instantsearch-core/src/__tests__/RoutingManager.test.ts deleted file mode 100644 index 16f9cf368d..0000000000 --- a/packages/instantsearch-core/src/__tests__/RoutingManager.test.ts +++ /dev/null @@ -1,553 +0,0 @@ -/** - * @jest-environment jsdom-global - */ - -import { createSearchClient } from '@instantsearch/mocks'; -import { wait } from '@instantsearch/testutils/wait'; -import { createWidget } from 'instantsearch-core/test/createWidget'; -import qs from 'qs'; - -import { - instantsearch, - historyRouter, - connectHitsPerPage, - connectSearchBox, -} from '..'; - -import type { Router, UiState, StateMapping, IndexUiState } from '../types'; -import type { JSDOM } from 'jsdom'; - -declare const jsdom: JSDOM; - -const createFakeRouter = (args: Partial = {}): Router => ({ - onUpdate(..._args) {}, - write(..._args) {}, - read() { - return {}; - }, - createURL(..._args) { - return ''; - }, - dispose() { - return undefined; - }, - ...args, -}); - -const createFakeStateMapping = ( - args: Partial = {} -): StateMapping => ({ - stateToRoute(uiState) { - return uiState; - }, - routeToState(routeState) { - return routeState; - }, - ...args, -}); - -type HistoryState = { - index: number; - entries: TEntry[]; - listeners: Array<(value: TEntry) => void>; -}; - -const createFakeHistory = >( - { - index = -1, - entries = [], - listeners = [], - }: HistoryState = {} as HistoryState -) => { - const state: HistoryState = { - index, - entries, - listeners, - }; - - return { - subscribe(listener: (entry: TEntry) => void) { - state.listeners.push(listener); - }, - push(value: TEntry) { - state.entries.push(value); - state.index++; - }, - back() { - state.index--; - listeners.forEach((listener) => { - listener(state.entries[state.index]); - }); - }, - }; -}; - -describe('RoutingManager', () => { - describe('within instantsearch', () => { - test('should write in the router on searchParameters change', async () => { - const searchClient = createSearchClient(); - const router = createFakeRouter({ - write: jest.fn(), - }); - - const search = instantsearch({ - indexName: 'indexName', - searchClient, - routing: { - router, - }, - }); - - const widget = createWidget({ - render: jest.fn(), - getWidgetUiState: jest.fn((uiState, { searchParameters }) => ({ - ...uiState, - q: searchParameters.query, - })), - getWidgetSearchParameters: jest.fn( - (searchParameters) => searchParameters - ), - }); - - search.addWidgets([widget]); - - search.start(); - - // initialization is done at this point - await wait(0); - - expect(widget.render).toHaveBeenCalledTimes(1); - expect(widget.getWidgetSearchParameters).toHaveBeenCalledTimes(1); - - await wait(0); - - expect(router.write).toHaveBeenCalledTimes(0); - - search.mainIndex.getHelper()!.setQuery('q'); // routing write updates on change - - await wait(0); - - expect(router.write).toHaveBeenCalledTimes(1); - expect(router.write).toHaveBeenCalledWith({ - indexName: { - q: 'q', - }, - }); - }); - - // eslint-disable-next-line jest/no-done-callback - test('should update the searchParameters on router state update', (done) => { - const searchClient = createSearchClient(); - - let onRouterUpdateCallback: (args: UiState) => void; - const router = createFakeRouter({ - onUpdate: (fn) => { - onRouterUpdateCallback = fn; - }, - }); - - const search = instantsearch({ - indexName: 'indexName', - searchClient, - routing: { - router, - }, - }); - - const widget = createWidget({ - render: jest.fn(), - getWidgetSearchParameters: jest.fn((searchParameters, { uiState }) => - searchParameters.setQuery(uiState.query!) - ), - }); - - search.addWidgets([widget]); - - search.start(); - - search.once('render', () => { - // initialization is done at this point - - expect(search.mainIndex.getHelper()!.state.query).toBeUndefined(); - - // this simulates a router update with a uiState of {query: 'a'} - onRouterUpdateCallback({ - indexName: { - query: 'a', - }, - }); - - search.once('render', () => { - // the router update triggers a new search - // and given that the widget reads q as a query parameter - expect(search.mainIndex.getHelper()!.state.query).toEqual('a'); - done(); - }); - }); - }); - - test('should keep the UI state up to date on state changes', async () => { - const searchClient = createSearchClient(); - const stateMapping = createFakeStateMapping({}); - const router = createFakeRouter({ - write: jest.fn(), - }); - - const search = instantsearch({ - indexName: 'indexName', - searchClient, - routing: { - stateMapping, - router, - }, - }); - - const fakeSearchBox = connectSearchBox(() => {})({}); - const fakeHitsPerPage = connectHitsPerPage(() => {})({ - items: [{ default: true, value: 1, label: 'one' }], - }); - - search.addWidgets([fakeSearchBox, fakeHitsPerPage]); - - search.start(); - - await wait(0); - - // Trigger an update - push a change - search.renderState.indexName.searchBox!.refine('Apple'); - - await wait(0); - - expect(router.write).toHaveBeenCalledTimes(1); - expect(router.write).toHaveBeenLastCalledWith({ - indexName: { - query: 'Apple', - }, - }); - - await wait(0); - - // Trigger change - search.removeWidgets([fakeHitsPerPage]); - - await wait(0); - - // The UI state hasn't changed so `router.write` wasn't called a second - // time - expect(router.write).toHaveBeenCalledTimes(1); - }); - - test('should keep the UI state up to date on router.update', async () => { - const searchClient = createSearchClient(); - const stateMapping = createFakeStateMapping({}); - const history = createFakeHistory(); - const router = createFakeRouter({ - onUpdate(fn) { - history.subscribe((state) => { - fn(state); - }); - }, - write: jest.fn((state) => { - history.push(state); - }), - }); - - const search = instantsearch({ - indexName: 'indexName', - searchClient, - routing: { - router, - stateMapping, - }, - }); - - const fakeSearchBox = connectSearchBox(() => {})({}); - const fakeHitsPerPage = connectHitsPerPage(() => {})({ - items: [{ default: true, value: 1, label: 'one' }], - }); - - search.addWidgets([fakeSearchBox, fakeHitsPerPage]); - - search.start(); - - await wait(0); - - // Trigger an update - push a change - search.renderState.indexName.searchBox!.refine('Apple'); - - await wait(0); - - expect(router.write).toHaveBeenCalledTimes(1); - expect(router.write).toHaveBeenLastCalledWith({ - indexName: { - query: 'Apple', - }, - }); - - // Trigger an update - push a change - search.renderState.indexName.searchBox!.refine('Apple iPhone'); - - await wait(0); - - expect(router.write).toHaveBeenCalledTimes(2); - expect(router.write).toHaveBeenLastCalledWith({ - indexName: { - query: 'Apple iPhone', - }, - }); - - await wait(0); - - // Trigger an update - Apple iPhone → Apple - history.back(); - - await wait(0); - - // Trigger change - search.removeWidgets([fakeHitsPerPage]); - - await wait(0); - - expect(router.write).toHaveBeenCalledTimes(3); - expect(router.write).toHaveBeenLastCalledWith({ - indexName: { - query: 'Apple', - }, - }); - }); - - test('skips duplicate route state entries', async () => { - let triggerChange = false; - const searchClient = createSearchClient(); - const stateMapping = createFakeStateMapping({ - stateToRoute(uiState) { - if (triggerChange) { - return { - ...uiState, - indexName: { - ...uiState.indexName, - triggerChange, - }, - }; - } - - return uiState; - }, - }); - const history = createFakeHistory(); - const router = createFakeRouter({ - onUpdate(fn) { - history.subscribe((state) => { - fn(state); - }); - }, - write: jest.fn((state) => { - history.push(state); - }), - }); - - const search = instantsearch({ - indexName: 'indexName', - searchClient, - routing: { - router, - stateMapping, - }, - }); - - const fakeSearchBox = connectSearchBox(() => {})({}); - const fakeHitsPerPage1 = connectHitsPerPage(() => {})({ - items: [{ default: true, value: 1, label: 'one' }], - }); - const fakeHitsPerPage2 = connectHitsPerPage(() => {})({ - items: [{ default: true, value: 1, label: 'one' }], - }); - - search.addWidgets([fakeSearchBox, fakeHitsPerPage1, fakeHitsPerPage2]); - - search.start(); - - await wait(0); - - // Trigger an update - push a change - search.renderState.indexName.searchBox!.refine('Apple'); - - await wait(0); - - expect(router.write).toHaveBeenCalledTimes(1); - expect(router.write).toHaveBeenLastCalledWith({ - indexName: { - query: 'Apple', - }, - }); - - // Trigger change without UI state change - search.removeWidgets([fakeHitsPerPage1]); - - await wait(0); - - expect(router.write).toHaveBeenCalledTimes(1); - - await wait(0); - - triggerChange = true; - // Trigger change without UI state change but with a route change - search.removeWidgets([fakeHitsPerPage2]); - - await wait(0); - - expect(router.write).toHaveBeenCalledTimes(2); - expect(router.write).toHaveBeenLastCalledWith({ - indexName: { - query: 'Apple', - triggerChange: true, - }, - }); - }); - }); - - describe('windowTitle', () => { - test('should update the window title with URL query params on first render', async () => { - jsdom.reconfigure({ - url: 'https://website.com/?query=query', - }); - - const setWindowTitle = jest.spyOn(window.document, 'title', 'set'); - const searchClient = createSearchClient(); - const stateMapping = createFakeStateMapping({}); - const router = historyRouter({ - windowTitle(routeState) { - return `Searching for "${routeState.query}"`; - }, - }); - - const search = instantsearch({ - indexName: 'instant_search', - searchClient, - routing: { - router, - stateMapping, - }, - }); - - const fakeSearchBox = connectSearchBox(() => {})({}); - - search.addWidgets([fakeSearchBox]); - search.start(); - - await wait(0); - - expect(setWindowTitle).toHaveBeenCalledTimes(1); - expect(setWindowTitle).toHaveBeenLastCalledWith('Searching for "query"'); - - setWindowTitle.mockRestore(); - }); - }); - - describe('parseURL', () => { - const createFakeUrlWithRefinements: ({ - length, - }: { - length: number; - }) => string = ({ length }) => - [ - 'https://website.com/', - Array.from( - { length }, - (_v, i) => `refinementList[brand][${i}]=brand-${i}` - ).join('&'), - ].join('?'); - - test('should parse refinements with more than 20 filters per category as array', () => { - jsdom.reconfigure({ - url: createFakeUrlWithRefinements({ length: 22 }), - }); - - const router = historyRouter(); - // @ts-expect-error: This method is considered private but we still use it - // in the test after the TypeScript migration. - // In a next refactor, we can consider changing this test implementation. - const parsedUrl = router.parseURL({ - qsModule: qs, - currentURL: new URL(window.location.href), - }); - - expect(parsedUrl.refinementList!.brand).toBeInstanceOf(Array); - expect(parsedUrl).toMatchInlineSnapshot(` - { - "refinementList": { - "brand": [ - "brand-0", - "brand-1", - "brand-2", - "brand-3", - "brand-4", - "brand-5", - "brand-6", - "brand-7", - "brand-8", - "brand-9", - "brand-10", - "brand-11", - "brand-12", - "brand-13", - "brand-14", - "brand-15", - "brand-16", - "brand-17", - "brand-18", - "brand-19", - "brand-20", - "brand-21", - ], - }, - } - `); - }); - - test('should support returning 100 refinements as array', () => { - jsdom.reconfigure({ - url: createFakeUrlWithRefinements({ length: 100 }), - }); - - const router = historyRouter(); - // @ts-expect-error: This method is considered private but we still use it - // in the test after the TypeScript migration. - // In a next refactor, we can consider changing this test implementation. - const parsedUrl = router.parseURL({ - qsModule: qs, - currentURL: new URL(window.location.href), - }); - - expect(parsedUrl.refinementList!.brand).toBeInstanceOf(Array); - }); - }); - - describe('createURL', () => { - it('returns an URL for a `routeState` with refinements', () => { - const router = historyRouter(); - const actual = router.createURL({ - query: 'iPhone', - page: 5, - }); - - expect(actual).toBe('https://website.com/?query=iPhone&page=5'); - }); - - it('returns an URL for an empty `routeState` with index', () => { - const router = historyRouter(); - const actual = router.createURL({ - indexName: {}, - }); - - expect(actual).toBe('https://website.com/'); - }); - - it('returns an URL for an empty `routeState`', () => { - const router = historyRouter(); - const actual = router.createURL({}); - - expect(actual).toBe('https://website.com/'); - }); - }); -}); diff --git a/packages/instantsearch-core/src/__tests__/instantsearch-routing.test.ts b/packages/instantsearch-core/src/__tests__/instantsearch-routing.test.ts new file mode 100644 index 0000000000..9e44116038 --- /dev/null +++ b/packages/instantsearch-core/src/__tests__/instantsearch-routing.test.ts @@ -0,0 +1,390 @@ +import { createSearchClient } from '@instantsearch/mocks'; +import { wait } from '@instantsearch/testutils/wait'; +import { createWidget } from 'instantsearch-core/test/createWidget'; + +import { instantsearch, connectHitsPerPage, connectSearchBox } from '..'; + +import type { Router, UiState, StateMapping } from '../types'; + +const createFakeRouter = (args: Partial = {}): Router => ({ + onUpdate(..._args) {}, + write(..._args) {}, + read() { + return {}; + }, + createURL(..._args) { + return ''; + }, + dispose() { + return undefined; + }, + ...args, +}); + +const createFakeStateMapping = ( + args: Partial = {} +): StateMapping => ({ + stateToRoute(uiState) { + return uiState; + }, + routeToState(routeState) { + return routeState; + }, + ...args, +}); + +type HistoryState = { + index: number; + entries: TEntry[]; + listeners: Array<(value: TEntry) => void>; +}; + +const createFakeHistory = >( + { + index = -1, + entries = [], + listeners = [], + }: HistoryState = {} as HistoryState +) => { + const state: HistoryState = { + index, + entries, + listeners, + }; + + return { + subscribe(listener: (entry: TEntry) => void) { + state.listeners.push(listener); + }, + push(value: TEntry) { + state.entries.push(value); + state.index++; + }, + back() { + state.index--; + listeners.forEach((listener) => { + listener(state.entries[state.index]); + }); + }, + }; +}; + +describe('instantsearch custom router', () => { + test('should write in the router on searchParameters change', async () => { + const searchClient = createSearchClient(); + const router = createFakeRouter({ + write: jest.fn(), + }); + + const search = instantsearch({ + indexName: 'indexName', + searchClient, + routing: { + router, + }, + }); + + const widget = createWidget({ + render: jest.fn(), + getWidgetUiState: jest.fn((uiState, { searchParameters }) => ({ + ...uiState, + q: searchParameters.query, + })), + getWidgetSearchParameters: jest.fn( + (searchParameters) => searchParameters + ), + }); + + search.addWidgets([widget]); + + search.start(); + + // initialization is done at this point + await wait(0); + + expect(widget.render).toHaveBeenCalledTimes(1); + expect(widget.getWidgetSearchParameters).toHaveBeenCalledTimes(1); + + await wait(0); + + expect(router.write).toHaveBeenCalledTimes(0); + + search.mainIndex.getHelper()!.setQuery('q'); // routing write updates on change + + await wait(0); + + expect(router.write).toHaveBeenCalledTimes(1); + expect(router.write).toHaveBeenCalledWith({ + indexName: { + q: 'q', + }, + }); + }); + + test('should update the searchParameters on router state update', async () => { + const searchClient = createSearchClient(); + + let onRouterUpdateCallback: (args: UiState) => void; + const router = createFakeRouter({ + onUpdate: (fn) => { + onRouterUpdateCallback = fn; + }, + }); + + const search = instantsearch({ + indexName: 'indexName', + searchClient, + routing: { + router, + }, + }); + + const widget = createWidget({ + render: jest.fn(), + getWidgetSearchParameters: jest.fn((searchParameters, { uiState }) => + searchParameters.setQuery(uiState.query!) + ), + }); + + search.addWidgets([widget]); + + search.start(); + + await wait(0); + // initialization is done at this point + + expect(search.mainIndex.getHelper()!.state.query).toBeUndefined(); + + // this simulates a router update with a uiState of {query: 'a'} + onRouterUpdateCallback!({ + indexName: { + query: 'a', + }, + }); + + await wait(0); + + // the router update triggers a new search + // and given that the widget reads q as a query parameter + expect(search.mainIndex.getHelper()!.state.query).toEqual('a'); + }); + + test('should keep the UI state up to date on state changes', async () => { + const searchClient = createSearchClient(); + const stateMapping = createFakeStateMapping({}); + const router = createFakeRouter({ + write: jest.fn(), + }); + + const search = instantsearch({ + indexName: 'indexName', + searchClient, + routing: { + stateMapping, + router, + }, + }); + + const fakeSearchBox = connectSearchBox(() => {})({}); + const fakeHitsPerPage = connectHitsPerPage(() => {})({ + items: [{ default: true, value: 1, label: 'one' }], + }); + + search.addWidgets([fakeSearchBox, fakeHitsPerPage]); + + search.start(); + + await wait(0); + + // Trigger an update - push a change + search.renderState.indexName.searchBox!.refine('Apple'); + + await wait(0); + + expect(router.write).toHaveBeenCalledTimes(1); + expect(router.write).toHaveBeenLastCalledWith({ + indexName: { + query: 'Apple', + }, + }); + + await wait(0); + + // Trigger change + search.removeWidgets([fakeHitsPerPage]); + + await wait(0); + + // The UI state hasn't changed so `router.write` wasn't called a second + // time + expect(router.write).toHaveBeenCalledTimes(1); + }); + + test('should keep the UI state up to date on router.update', async () => { + const searchClient = createSearchClient(); + const stateMapping = createFakeStateMapping({}); + const history = createFakeHistory(); + const router = createFakeRouter({ + onUpdate(fn) { + history.subscribe((state) => { + fn(state); + }); + }, + write: jest.fn((state) => { + history.push(state); + }), + }); + + const search = instantsearch({ + indexName: 'indexName', + searchClient, + routing: { + router, + stateMapping, + }, + }); + + const fakeSearchBox = connectSearchBox(() => {})({}); + const fakeHitsPerPage = connectHitsPerPage(() => {})({ + items: [{ default: true, value: 1, label: 'one' }], + }); + + search.addWidgets([fakeSearchBox, fakeHitsPerPage]); + + search.start(); + + await wait(0); + + // Trigger an update - push a change + search.renderState.indexName.searchBox!.refine('Apple'); + + await wait(0); + + expect(router.write).toHaveBeenCalledTimes(1); + expect(router.write).toHaveBeenLastCalledWith({ + indexName: { + query: 'Apple', + }, + }); + + // Trigger an update - push a change + search.renderState.indexName.searchBox!.refine('Apple iPhone'); + + await wait(0); + + expect(router.write).toHaveBeenCalledTimes(2); + expect(router.write).toHaveBeenLastCalledWith({ + indexName: { + query: 'Apple iPhone', + }, + }); + + await wait(0); + + // Trigger an update - Apple iPhone → Apple + history.back(); + + await wait(0); + + // Trigger change + search.removeWidgets([fakeHitsPerPage]); + + await wait(0); + + expect(router.write).toHaveBeenCalledTimes(3); + expect(router.write).toHaveBeenLastCalledWith({ + indexName: { + query: 'Apple', + }, + }); + }); + + test('skips duplicate route state entries', async () => { + let triggerChange = false; + const searchClient = createSearchClient(); + const stateMapping = createFakeStateMapping({ + stateToRoute(uiState) { + if (triggerChange) { + return { + ...uiState, + indexName: { + ...uiState.indexName, + triggerChange, + }, + }; + } + + return uiState; + }, + }); + const history = createFakeHistory(); + const router = createFakeRouter({ + onUpdate(fn) { + history.subscribe((state) => { + fn(state); + }); + }, + write: jest.fn((state) => { + history.push(state); + }), + }); + + const search = instantsearch({ + indexName: 'indexName', + searchClient, + routing: { + router, + stateMapping, + }, + }); + + const fakeSearchBox = connectSearchBox(() => {})({}); + const fakeHitsPerPage1 = connectHitsPerPage(() => {})({ + items: [{ default: true, value: 1, label: 'one' }], + }); + const fakeHitsPerPage2 = connectHitsPerPage(() => {})({ + items: [{ default: true, value: 1, label: 'one' }], + }); + + search.addWidgets([fakeSearchBox, fakeHitsPerPage1, fakeHitsPerPage2]); + + search.start(); + + await wait(0); + + // Trigger an update - push a change + search.renderState.indexName.searchBox!.refine('Apple'); + + await wait(0); + + expect(router.write).toHaveBeenCalledTimes(1); + expect(router.write).toHaveBeenLastCalledWith({ + indexName: { + query: 'Apple', + }, + }); + + // Trigger change without UI state change + search.removeWidgets([fakeHitsPerPage1]); + + await wait(0); + + expect(router.write).toHaveBeenCalledTimes(1); + + await wait(0); + + triggerChange = true; + // Trigger change without UI state change but with a route change + search.removeWidgets([fakeHitsPerPage2]); + + await wait(0); + + expect(router.write).toHaveBeenCalledTimes(2); + expect(router.write).toHaveBeenLastCalledWith({ + indexName: { + query: 'Apple', + triggerChange: true, + }, + }); + }); +}); diff --git a/packages/instantsearch-core/src/routing/__tests__/historyRouter-integration.test.ts b/packages/instantsearch-core/src/routing/__tests__/historyRouter-integration.test.ts index 99802d69a9..fe4f49f5dc 100644 --- a/packages/instantsearch-core/src/routing/__tests__/historyRouter-integration.test.ts +++ b/packages/instantsearch-core/src/routing/__tests__/historyRouter-integration.test.ts @@ -17,126 +17,128 @@ beforeEach(() => { const writeDelay = 10; const writeWait = 10 * writeDelay; -test('keeps url with cleanUrlOnDispose: false', async () => { - const router = historyRouter({ writeDelay, cleanUrlOnDispose: false }); - - const indexName = 'indexName'; - const search = instantsearch({ - indexName, - searchClient: createSearchClient(), - routing: { - router, - }, - }); - - search.addWidgets([ - connectPagination(() => {})({}), - index({ indexName }).addWidgets([connectSearchBox(() => {})({})]), - ]); +describe('historyRouter({ cleanUrlOnDispose })', () => { + test('keeps url with cleanUrlOnDispose: false', async () => { + const router = historyRouter({ writeDelay, cleanUrlOnDispose: false }); - search.start(); + const indexName = 'indexName'; + const search = instantsearch({ + indexName, + searchClient: createSearchClient(), + routing: { + router, + }, + }); - expect(window.location.search).toBe(''); + search.addWidgets([ + connectPagination(() => {})({}), + index({ indexName }).addWidgets([connectSearchBox(() => {})({})]), + ]); - // on nested index - search.renderState[indexName].searchBox!.refine('test'); - // on main index - search.renderState[indexName].pagination!.refine(39); + search.start(); - await wait(writeWait); + expect(window.location.search).toBe(''); - expect(window.location.search).toBe( - `?${encodeURI('indexName[page]=40&indexName[query]=test')}` - ); + // on nested index + search.renderState[indexName].searchBox!.refine('test'); + // on main index + search.renderState[indexName].pagination!.refine(39); - search.dispose(); + await wait(writeWait); - await wait(writeWait); + expect(window.location.search).toBe( + `?${encodeURI('indexName[page]=40&indexName[query]=test')}` + ); - // URL has not been cleaned - expect(window.location.search).toBe( - `?${encodeURI('indexName[page]=40&indexName[query]=test')}` - ); -}); + search.dispose(); -test('clears url with cleanUrlOnDispose: true', async () => { - const router = historyRouter({ writeDelay, cleanUrlOnDispose: true }); + await wait(writeWait); - const indexName = 'indexName'; - const search = instantsearch({ - indexName, - searchClient: createSearchClient(), - routing: { - router, - }, + // URL has not been cleaned + expect(window.location.search).toBe( + `?${encodeURI('indexName[page]=40&indexName[query]=test')}` + ); }); - search.addWidgets([ - connectPagination(() => {})({}), - index({ indexName }).addWidgets([connectSearchBox(() => {})({})]), - ]); + test('clears url with cleanUrlOnDispose: true', async () => { + const router = historyRouter({ writeDelay, cleanUrlOnDispose: true }); - search.start(); + const indexName = 'indexName'; + const search = instantsearch({ + indexName, + searchClient: createSearchClient(), + routing: { + router, + }, + }); - expect(window.location.search).toBe(''); + search.addWidgets([ + connectPagination(() => {})({}), + index({ indexName }).addWidgets([connectSearchBox(() => {})({})]), + ]); - // on nested index - search.renderState[indexName].searchBox!.refine('test'); - // on main index - search.renderState[indexName].pagination!.refine(39); + search.start(); - await wait(writeWait); + expect(window.location.search).toBe(''); - expect(window.location.search).toBe( - `?${encodeURI('indexName[page]=40&indexName[query]=test')}` - ); + // on nested index + search.renderState[indexName].searchBox!.refine('test'); + // on main index + search.renderState[indexName].pagination!.refine(39); - search.dispose(); + await wait(writeWait); - await wait(writeWait); + expect(window.location.search).toBe( + `?${encodeURI('indexName[page]=40&indexName[query]=test')}` + ); - // URL has been cleaned - expect(window.location.search).toBe(''); -}); + search.dispose(); -test('does not clear url with cleanUrlOnDispose: undefined', async () => { - const router = historyRouter({ writeDelay }); + await wait(writeWait); - const indexName = 'indexName'; - const search = instantsearch({ - indexName, - searchClient: createSearchClient(), - routing: { - router, - }, + // URL has been cleaned + expect(window.location.search).toBe(''); }); - search.addWidgets([ - connectPagination(() => {})({}), - index({ indexName }).addWidgets([connectSearchBox(() => {})({})]), - ]); + test('does not clear url with cleanUrlOnDispose: undefined', async () => { + const router = historyRouter({ writeDelay }); - search.start(); + const indexName = 'indexName'; + const search = instantsearch({ + indexName, + searchClient: createSearchClient(), + routing: { + router, + }, + }); - expect(window.location.search).toBe(''); + search.addWidgets([ + connectPagination(() => {})({}), + index({ indexName }).addWidgets([connectSearchBox(() => {})({})]), + ]); - // on nested index - search.renderState[indexName].searchBox!.refine('test'); - // on main index - search.renderState[indexName].pagination!.refine(39); + search.start(); - await wait(writeWait); + expect(window.location.search).toBe(''); - expect(window.location.search).toBe( - `?${encodeURI('indexName[page]=40&indexName[query]=test')}` - ); + // on nested index + search.renderState[indexName].searchBox!.refine('test'); + // on main index + search.renderState[indexName].pagination!.refine(39); - search.dispose(); + await wait(writeWait); - await wait(writeWait); + expect(window.location.search).toBe( + `?${encodeURI('indexName[page]=40&indexName[query]=test')}` + ); - // URL has not been cleaned - expect(window.location.search).toBe( - `?${encodeURI('indexName[page]=40&indexName[query]=test')}` - ); + search.dispose(); + + await wait(writeWait); + + // URL has not been cleaned + expect(window.location.search).toBe( + `?${encodeURI('indexName[page]=40&indexName[query]=test')}` + ); + }); }); diff --git a/packages/instantsearch-core/src/routing/__tests__/historyRouter.test.ts b/packages/instantsearch-core/src/routing/__tests__/historyRouter.test.ts index 74a0e8ed52..406632cd47 100644 --- a/packages/instantsearch-core/src/routing/__tests__/historyRouter.test.ts +++ b/packages/instantsearch-core/src/routing/__tests__/historyRouter.test.ts @@ -10,13 +10,14 @@ import { instantsearch, noop, warnCache, + connectSearchBox, } from '../..'; -import type { UiState } from '../../types'; +import type { IndexUiState, UiState } from '../../types'; jest.useFakeTimers(); -describe('life cycle', () => { +describe('historyRouter', () => { const originalWindow = (global as any).window; beforeEach(() => { @@ -81,7 +82,7 @@ describe('life cycle', () => { }); describe('getCurrentURL', () => { - test('calls getCurrentURL on windowTitle', () => { + test('calls getCurrentURL on load', () => { const getCurrentURL = jest.fn(() => new URL(window.location.href)); historyRouter({ @@ -366,5 +367,127 @@ describe('life cycle', () => { .toWarnDev(`[InstantSearch]: The URL returned by the \`createURL\` function is invalid. Please make sure it returns an absolute URL to avoid issues, e.g: \`https://algolia.com/search?query=iphone\`.`); }); + + it('returns an URL for a `routeState` with refinements', () => { + const router = historyRouter(); + const actual = router.createURL({ + query: 'iPhone', + page: 5, + }); + + expect(actual).toBe('http://localhost/?query=iPhone&page=5'); + }); + + it('returns an URL for an empty `routeState` with index', () => { + const router = historyRouter(); + const actual = router.createURL({ + indexName: {}, + }); + + expect(actual).toBe('http://localhost/'); + }); + + it('returns an URL for an empty `routeState`', () => { + const router = historyRouter(); + const actual = router.createURL({}); + + expect(actual).toBe('http://localhost/'); + }); + }); + + describe('read', () => { + const createFakeUrlWithRefinements: ({ + length, + }: { + length: number; + }) => string = ({ length }) => + `http://localhost/?${Array.from( + { length }, + (_v, i) => `refinementList[brand][${i}]=brand-${i}` + ).join('&')}`; + + test('should parse refinements with more than 20 filters per category as array', () => { + history.pushState({}, '', createFakeUrlWithRefinements({ length: 22 })); + + const router = historyRouter(); + const parsedUrl = router.read(); + + expect(parsedUrl.refinementList!.brand).toBeInstanceOf(Array); + expect(parsedUrl.refinementList!.brand).toHaveLength(22); + expect(parsedUrl).toMatchInlineSnapshot(` + { + "refinementList": { + "brand": [ + "brand-0", + "brand-1", + "brand-2", + "brand-3", + "brand-4", + "brand-5", + "brand-6", + "brand-7", + "brand-8", + "brand-9", + "brand-10", + "brand-11", + "brand-12", + "brand-13", + "brand-14", + "brand-15", + "brand-16", + "brand-17", + "brand-18", + "brand-19", + "brand-20", + "brand-21", + ], + }, + } + `); + }); + + test('should support returning 100 refinements as array', () => { + history.pushState({}, '', createFakeUrlWithRefinements({ length: 100 })); + + const router = historyRouter(); + const parsedUrl = router.read(); + + expect(parsedUrl.refinementList!.brand).toBeInstanceOf(Array); + expect(parsedUrl.refinementList!.brand).toHaveLength(100); + }); + }); + + describe('windowTitle', () => { + test('should update the window title with URL query params on first render', () => { + history.pushState({}, '', 'http://localhost/?query=query'); + + const setWindowTitle = jest.spyOn(window.document, 'title', 'set'); + const searchClient = createSearchClient(); + const router = historyRouter({ + windowTitle(routeState) { + return `Searching for "${routeState.query}"`; + }, + }); + + const search = instantsearch({ + indexName: 'instant_search', + searchClient, + routing: { + router, + }, + }); + + const fakeSearchBox = connectSearchBox(() => {})({}); + + search.addWidgets([fakeSearchBox]); + search.start(); + + expect(true).toBe(true); + + expect(setWindowTitle).toHaveBeenCalledTimes(1); + expect(setWindowTitle).toHaveBeenLastCalledWith('Searching for "query"'); + + setWindowTitle.mockRestore(); + }); }); }); diff --git a/packages/instantsearch-core/src/routing/__tests__/routing-clean/correct-urls.test.ts b/packages/instantsearch-core/src/routing/__tests__/routing-clean/correct-urls.test.ts new file mode 100644 index 0000000000..34d790db03 --- /dev/null +++ b/packages/instantsearch-core/src/routing/__tests__/routing-clean/correct-urls.test.ts @@ -0,0 +1,87 @@ +/** + * @jest-environment jsdom + */ + +import { createSearchClient } from '@instantsearch/mocks'; +import { wait } from '@instantsearch/testutils/wait'; + +import { + instantsearch, + index, + historyRouter, + connectPagination, + connectSearchBox, +} from '../../..'; + +beforeEach(() => { + window.history.pushState({}, '', '/'); +}); + +const writeDelay = 10; +const writeWait = 10 * writeDelay; + +test('correct URL for widgets', async () => { + const router = historyRouter({ cleanUrlOnDispose: true, writeDelay }); + + const indexName = 'indexName'; + const search = instantsearch({ + indexName, + searchClient: createSearchClient(), + routing: { + router, + }, + }); + + search.addWidgets([ + connectPagination(() => {})({}), + connectSearchBox(() => {})({}), + ]); + + search.start(); + + expect(window.location.search).toBe(''); + + // on nested index + search.renderState[indexName].searchBox!.refine('test'); + // on main index + search.renderState[indexName].pagination!.refine(39); + + await wait(writeWait); + + expect(window.location.search).toBe( + `?${encodeURI('indexName[page]=40&indexName[query]=test')}` + ); +}); + +test('correct URL for widgets in indices with repeated indexId', async () => { + const router = historyRouter({ cleanUrlOnDispose: true, writeDelay }); + + const indexName = 'indexName'; + const search = instantsearch({ + indexName, + searchClient: createSearchClient(), + routing: { + router, + }, + }); + + search.addWidgets([ + connectPagination(() => {})({}), + index({ indexName }).addWidgets([connectSearchBox(() => {})({})]), + ]); + + search.start(); + + expect(window.location.search).toBe(''); + + // on nested index + search.renderState[indexName].searchBox!.refine('test'); + // on main index + search.renderState[indexName].pagination!.refine(39); + + await wait(writeWait); + + expect(window.location.search).toBe( + `?${encodeURI('indexName[page]=40&indexName[query]=test')}` + ); +}); diff --git a/packages/instantsearch-core/src/routing/__tests__/dispose-start-clean.test.ts b/packages/instantsearch-core/src/routing/__tests__/routing-clean/dispose-start.test.ts similarity index 97% rename from packages/instantsearch-core/src/routing/__tests__/dispose-start-clean.test.ts rename to packages/instantsearch-core/src/routing/__tests__/routing-clean/dispose-start.test.ts index c3c8028bb2..517ca29770 100644 --- a/packages/instantsearch-core/src/routing/__tests__/dispose-start-clean.test.ts +++ b/packages/instantsearch-core/src/routing/__tests__/routing-clean/dispose-start.test.ts @@ -5,9 +5,9 @@ import { createSearchClient } from '@instantsearch/mocks'; import { wait } from '@instantsearch/testutils/wait'; -import { instantsearch, historyRouter, connectSearchBox } from '../..'; +import { instantsearch, historyRouter, connectSearchBox } from '../../..'; -import type { InstantSearch } from '../..'; +import type { InstantSearch } from '../../..'; /* eslint no-lone-blocks: "off" */ @@ -36,8 +36,8 @@ describe('routing back and forth to an InstantSearch instance', () => { searchClient: createSearchClient(), routing: { router: historyRouter({ - writeDelay, cleanUrlOnDispose: true, + writeDelay, }), }, }); diff --git a/packages/instantsearch-core/src/routing/__tests__/routing-clean/duplicate-url.test.ts b/packages/instantsearch-core/src/routing/__tests__/routing-clean/duplicate-url.test.ts new file mode 100644 index 0000000000..184166d622 --- /dev/null +++ b/packages/instantsearch-core/src/routing/__tests__/routing-clean/duplicate-url.test.ts @@ -0,0 +1,86 @@ +/** + * @jest-environment jsdom + */ + +import { createSearchClient } from '@instantsearch/mocks'; +import { wait } from '@instantsearch/testutils/wait'; + +import { + instantsearch, + historyRouter, + connectPagination, + connectSearchBox, +} from '../../..'; + +/* eslint no-lone-blocks: "off" */ + +const writeDelay = 10; +const writeWait = 10 * writeDelay; + +test('does not write the same URL twice', async () => { + // -- Flow + // 0. page is filtered out via stateMapping + // 1. Initial: '/' + // 2. Refine query: '/?indexName[query]=Apple' (writes) + // 3. Refine page: '/?indexName[query]=Apple' (does not write) + + const pushState = jest.spyOn(window.history, 'pushState'); + + const search = instantsearch({ + indexName: 'indexName', + searchClient: createSearchClient(), + routing: { + stateMapping: { + stateToRoute(uiState) { + return Object.fromEntries( + Object.entries(uiState).map( + ([indexId, { page, ...indexUiState }]) => [indexId, indexUiState] + ) + ); + }, + routeToState(routeState) { + return routeState; + }, + }, + router: historyRouter({ + cleanUrlOnDispose: true, + writeDelay, + }), + }, + }); + + // 1. Initial: '/' + { + search.addWidgets([ + connectSearchBox(() => {})({}), + connectPagination(() => {})({}), + ]); + search.start(); + + await wait(writeWait); + expect(window.location.search).toEqual(''); + expect(pushState).toHaveBeenCalledTimes(0); + } + + // 2. Refine query: '/?indexName[query]=Apple' + { + search.renderState.indexName.searchBox!.refine('Apple'); + + await wait(writeWait); + expect(window.location.search).toEqual( + `?${encodeURI('indexName[query]=Apple')}` + ); + expect(pushState).toHaveBeenCalledTimes(1); + } + + // 3. Refine page: '/?indexName[query]=Apple' + { + search.renderState.indexName.pagination!.refine(2); + + await wait(writeWait); + expect(window.location.search).toEqual( + `?${encodeURI('indexName[query]=Apple')}` + ); + expect(pushState).toHaveBeenCalledTimes(1); + } +}); diff --git a/packages/instantsearch-core/src/routing/__tests__/routing-clean/external-influence.test.ts b/packages/instantsearch-core/src/routing/__tests__/routing-clean/external-influence.test.ts new file mode 100644 index 0000000000..62731b57d5 --- /dev/null +++ b/packages/instantsearch-core/src/routing/__tests__/routing-clean/external-influence.test.ts @@ -0,0 +1,78 @@ +/** + * @jest-environment jsdom + */ + +import { createSearchClient } from '@instantsearch/mocks'; +import { wait } from '@instantsearch/testutils/wait'; + +import { instantsearch, historyRouter, connectSearchBox } from '../../..'; + +/* eslint no-lone-blocks: "off" */ + +const writeDelay = 10; +const writeWait = 10 * writeDelay; + +describe('routing with external influence', () => { + test('keeps on working when the URL is updated by another program', async () => { + // -- Flow + // 1. Initial: '/' + // 2. Refine: '/?indexName[query]=Apple' + // 3. External influence: '/about' + // 4. Refine: '/about?indexName[query]=Samsung' + + const pushState = jest.spyOn(window.history, 'pushState'); + + const search = instantsearch({ + indexName: 'indexName', + searchClient: createSearchClient(), + routing: { + router: historyRouter({ + cleanUrlOnDispose: true, + writeDelay, + }), + }, + }); + + // 1. Initial: '/' + { + search.addWidgets([connectSearchBox(() => {})({})]); + search.start(); + + await wait(writeWait); + expect(window.location.search).toEqual(''); + expect(pushState).toHaveBeenCalledTimes(0); + } + + // 2. Refine: '/?indexName[query]=Apple' + { + search.renderState.indexName.searchBox!.refine('Apple'); + + await wait(writeWait); + expect(window.location.search).toEqual( + `?${encodeURI('indexName[query]=Apple')}` + ); + expect(pushState).toHaveBeenCalledTimes(1); + } + + // 3. External influence: '/about' + { + window.history.pushState({}, '', '/about'); + + await wait(writeWait); + expect(window.location.pathname).toEqual('/about'); + expect(window.location.search).toEqual(''); + expect(pushState).toHaveBeenCalledTimes(2); + } + + // 4. Refine: '/about?indexName[query]=Samsung' + { + search.renderState.indexName.searchBox!.refine('Samsung'); + + await wait(writeWait); + expect(window.location.pathname).toEqual('/about'); + expect(window.location.search).toEqual( + `?${encodeURI('indexName[query]=Samsung')}` + ); + } + }); +}); diff --git a/packages/instantsearch-core/src/routing/__tests__/modal-clean.test.ts b/packages/instantsearch-core/src/routing/__tests__/routing-clean/modal.test.ts similarity index 99% rename from packages/instantsearch-core/src/routing/__tests__/modal-clean.test.ts rename to packages/instantsearch-core/src/routing/__tests__/routing-clean/modal.test.ts index 26a8eafb8e..f30f5d1c3e 100644 --- a/packages/instantsearch-core/src/routing/__tests__/modal-clean.test.ts +++ b/packages/instantsearch-core/src/routing/__tests__/routing-clean/modal.test.ts @@ -5,7 +5,7 @@ import { createSearchClient } from '@instantsearch/mocks'; import { wait } from '@instantsearch/testutils/wait'; -import { instantsearch, historyRouter, connectSearchBox } from '../..'; +import { instantsearch, historyRouter, connectSearchBox } from '../../..'; /* eslint no-lone-blocks: "off" */ @@ -26,8 +26,8 @@ describe('routing with no navigation', () => { searchClient: createSearchClient(), routing: { router: historyRouter({ - writeDelay, cleanUrlOnDispose: true, + writeDelay, }), }, }); diff --git a/packages/instantsearch-core/src/routing/__tests__/spa-debounced-clean.test.ts b/packages/instantsearch-core/src/routing/__tests__/routing-clean/spa-debounced.test.ts similarity index 99% rename from packages/instantsearch-core/src/routing/__tests__/spa-debounced-clean.test.ts rename to packages/instantsearch-core/src/routing/__tests__/routing-clean/spa-debounced.test.ts index 3f2f371002..39111eeeb3 100644 --- a/packages/instantsearch-core/src/routing/__tests__/spa-debounced-clean.test.ts +++ b/packages/instantsearch-core/src/routing/__tests__/routing-clean/spa-debounced.test.ts @@ -5,7 +5,7 @@ import { createSearchClient } from '@instantsearch/mocks'; import { wait } from '@instantsearch/testutils/wait'; -import { instantsearch, historyRouter, connectSearchBox } from '../..'; +import { instantsearch, historyRouter, connectSearchBox } from '../../..'; /* eslint no-lone-blocks: "off" */ @@ -29,8 +29,8 @@ describe('routing with debounced third-party client-side router', () => { searchClient: createSearchClient(), routing: { router: historyRouter({ - writeDelay, cleanUrlOnDispose: true, + writeDelay, }), }, }); diff --git a/packages/instantsearch-core/src/routing/__tests__/spa-replace-state-clean.test.ts b/packages/instantsearch-core/src/routing/__tests__/routing-clean/spa-replace-state.test.ts similarity index 99% rename from packages/instantsearch-core/src/routing/__tests__/spa-replace-state-clean.test.ts rename to packages/instantsearch-core/src/routing/__tests__/routing-clean/spa-replace-state.test.ts index 83281a1ec1..e78d95441a 100644 --- a/packages/instantsearch-core/src/routing/__tests__/spa-replace-state-clean.test.ts +++ b/packages/instantsearch-core/src/routing/__tests__/routing-clean/spa-replace-state.test.ts @@ -5,7 +5,7 @@ import { createSearchClient } from '@instantsearch/mocks'; import { wait } from '@instantsearch/testutils/wait'; -import { instantsearch, historyRouter, connectSearchBox } from '../..'; +import { instantsearch, historyRouter, connectSearchBox } from '../../..'; /* eslint no-lone-blocks: "off" */ @@ -31,8 +31,8 @@ describe('routing using `replaceState`', () => { searchClient: createSearchClient(), routing: { router: historyRouter({ - writeDelay, cleanUrlOnDispose: true, + writeDelay, }), }, }); diff --git a/packages/instantsearch-core/src/routing/__tests__/spa-clean.test.ts b/packages/instantsearch-core/src/routing/__tests__/routing-clean/spa.test.ts similarity index 99% rename from packages/instantsearch-core/src/routing/__tests__/spa-clean.test.ts rename to packages/instantsearch-core/src/routing/__tests__/routing-clean/spa.test.ts index fc01ea85c7..1adb7869de 100644 --- a/packages/instantsearch-core/src/routing/__tests__/spa-clean.test.ts +++ b/packages/instantsearch-core/src/routing/__tests__/routing-clean/spa.test.ts @@ -5,7 +5,7 @@ import { createSearchClient } from '@instantsearch/mocks'; import { wait } from '@instantsearch/testutils/wait'; -import { instantsearch, historyRouter, connectSearchBox } from '../..'; +import { instantsearch, historyRouter, connectSearchBox } from '../../..'; /* eslint no-lone-blocks: "off" */ @@ -29,8 +29,8 @@ describe('routing with third-party client-side router', () => { searchClient: createSearchClient(), routing: { router: historyRouter({ - writeDelay, cleanUrlOnDispose: true, + writeDelay, }), }, }); diff --git a/packages/instantsearch-core/src/routing/__tests__/correct-urls.test.ts b/packages/instantsearch-core/src/routing/__tests__/routing/correct-urls.test.ts similarity index 98% rename from packages/instantsearch-core/src/routing/__tests__/correct-urls.test.ts rename to packages/instantsearch-core/src/routing/__tests__/routing/correct-urls.test.ts index 436e1c48e3..e030626209 100644 --- a/packages/instantsearch-core/src/routing/__tests__/correct-urls.test.ts +++ b/packages/instantsearch-core/src/routing/__tests__/routing/correct-urls.test.ts @@ -11,7 +11,7 @@ import { historyRouter, connectPagination, connectSearchBox, -} from '../..'; +} from '../../..'; beforeEach(() => { window.history.pushState({}, '', '/'); diff --git a/packages/instantsearch-core/src/routing/__tests__/dispose-start.test.ts b/packages/instantsearch-core/src/routing/__tests__/routing/dispose-start.test.ts similarity index 97% rename from packages/instantsearch-core/src/routing/__tests__/dispose-start.test.ts rename to packages/instantsearch-core/src/routing/__tests__/routing/dispose-start.test.ts index 331c701210..8210a85f1e 100644 --- a/packages/instantsearch-core/src/routing/__tests__/dispose-start.test.ts +++ b/packages/instantsearch-core/src/routing/__tests__/routing/dispose-start.test.ts @@ -5,9 +5,9 @@ import { createSearchClient } from '@instantsearch/mocks'; import { wait } from '@instantsearch/testutils/wait'; -import { instantsearch, historyRouter, connectSearchBox } from '../..'; +import { instantsearch, historyRouter, connectSearchBox } from '../../..'; -import type { InstantSearch } from '../..'; +import type { InstantSearch } from '../../..'; /* eslint no-lone-blocks: "off" */ diff --git a/packages/instantsearch-core/src/routing/__tests__/duplicate-url.test.ts b/packages/instantsearch-core/src/routing/__tests__/routing/duplicate-url.test.ts similarity index 99% rename from packages/instantsearch-core/src/routing/__tests__/duplicate-url.test.ts rename to packages/instantsearch-core/src/routing/__tests__/routing/duplicate-url.test.ts index d93f293f76..1c82fea8ee 100644 --- a/packages/instantsearch-core/src/routing/__tests__/duplicate-url.test.ts +++ b/packages/instantsearch-core/src/routing/__tests__/routing/duplicate-url.test.ts @@ -10,7 +10,7 @@ import { historyRouter, connectPagination, connectSearchBox, -} from '../..'; +} from '../../..'; /* eslint no-lone-blocks: "off" */ diff --git a/packages/instantsearch-core/src/routing/__tests__/external-influence.test.ts b/packages/instantsearch-core/src/routing/__tests__/routing/external-influence.test.ts similarity index 99% rename from packages/instantsearch-core/src/routing/__tests__/external-influence.test.ts rename to packages/instantsearch-core/src/routing/__tests__/routing/external-influence.test.ts index 616546f016..2bbd787ea5 100644 --- a/packages/instantsearch-core/src/routing/__tests__/external-influence.test.ts +++ b/packages/instantsearch-core/src/routing/__tests__/routing/external-influence.test.ts @@ -5,7 +5,7 @@ import { createSearchClient } from '@instantsearch/mocks'; import { wait } from '@instantsearch/testutils/wait'; -import { instantsearch, historyRouter, connectSearchBox } from '../..'; +import { instantsearch, historyRouter, connectSearchBox } from '../../..'; /* eslint no-lone-blocks: "off" */ diff --git a/packages/instantsearch-core/src/routing/__tests__/modal.test.ts b/packages/instantsearch-core/src/routing/__tests__/routing/modal.test.ts similarity index 99% rename from packages/instantsearch-core/src/routing/__tests__/modal.test.ts rename to packages/instantsearch-core/src/routing/__tests__/routing/modal.test.ts index 3f7dcb606c..02d100e6d5 100644 --- a/packages/instantsearch-core/src/routing/__tests__/modal.test.ts +++ b/packages/instantsearch-core/src/routing/__tests__/routing/modal.test.ts @@ -5,7 +5,7 @@ import { createSearchClient } from '@instantsearch/mocks'; import { wait } from '@instantsearch/testutils/wait'; -import { instantsearch, historyRouter, connectSearchBox } from '../..'; +import { instantsearch, historyRouter, connectSearchBox } from '../../..'; /* eslint no-lone-blocks: "off" */ diff --git a/packages/instantsearch-core/src/routing/__tests__/spa-debounced.test.ts b/packages/instantsearch-core/src/routing/__tests__/routing/spa-debounced.test.ts similarity index 99% rename from packages/instantsearch-core/src/routing/__tests__/spa-debounced.test.ts rename to packages/instantsearch-core/src/routing/__tests__/routing/spa-debounced.test.ts index 3b58d5dead..37bb76d147 100644 --- a/packages/instantsearch-core/src/routing/__tests__/spa-debounced.test.ts +++ b/packages/instantsearch-core/src/routing/__tests__/routing/spa-debounced.test.ts @@ -5,7 +5,7 @@ import { createSearchClient } from '@instantsearch/mocks'; import { wait } from '@instantsearch/testutils/wait'; -import { instantsearch, historyRouter, connectSearchBox } from '../..'; +import { instantsearch, historyRouter, connectSearchBox } from '../../..'; /* eslint no-lone-blocks: "off" */ diff --git a/packages/instantsearch-core/src/routing/__tests__/spa-replace-state.test.ts b/packages/instantsearch-core/src/routing/__tests__/routing/spa-replace-state.test.ts similarity index 99% rename from packages/instantsearch-core/src/routing/__tests__/spa-replace-state.test.ts rename to packages/instantsearch-core/src/routing/__tests__/routing/spa-replace-state.test.ts index 25281cc06e..41dcea0bc4 100644 --- a/packages/instantsearch-core/src/routing/__tests__/spa-replace-state.test.ts +++ b/packages/instantsearch-core/src/routing/__tests__/routing/spa-replace-state.test.ts @@ -5,7 +5,7 @@ import { createSearchClient } from '@instantsearch/mocks'; import { wait } from '@instantsearch/testutils/wait'; -import { instantsearch, historyRouter, connectSearchBox } from '../..'; +import { instantsearch, historyRouter, connectSearchBox } from '../../..'; /* eslint no-lone-blocks: "off" */ diff --git a/packages/instantsearch-core/src/routing/__tests__/spa.test.ts b/packages/instantsearch-core/src/routing/__tests__/routing/spa.test.ts similarity index 99% rename from packages/instantsearch-core/src/routing/__tests__/spa.test.ts rename to packages/instantsearch-core/src/routing/__tests__/routing/spa.test.ts index 3ee8a48362..c719168f53 100644 --- a/packages/instantsearch-core/src/routing/__tests__/spa.test.ts +++ b/packages/instantsearch-core/src/routing/__tests__/routing/spa.test.ts @@ -5,7 +5,7 @@ import { createSearchClient } from '@instantsearch/mocks'; import { wait } from '@instantsearch/testutils/wait'; -import { instantsearch, historyRouter, connectSearchBox } from '../..'; +import { instantsearch, historyRouter, connectSearchBox } from '../../..'; /* eslint no-lone-blocks: "off" */ diff --git a/packages/instantsearch-core/src/routing/historyRouter.ts b/packages/instantsearch-core/src/routing/historyRouter.ts index 929201edbd..52da910c76 100644 --- a/packages/instantsearch-core/src/routing/historyRouter.ts +++ b/packages/instantsearch-core/src/routing/historyRouter.ts @@ -41,258 +41,9 @@ const setWindowTitle = (title?: string): void => { } }; -class BrowserHistory implements Router { - public $$type = 'ais.browser'; - /** - * Transforms a UI state into a title for the page. - */ - private readonly windowTitle?: BrowserHistoryArgs['windowTitle']; - /** - * Time in milliseconds before performing a write in the history. - * It prevents from adding too many entries in the history and - * makes the back button more usable. - * - * @default 400 - */ - private readonly writeDelay: Required< - BrowserHistoryArgs - >['writeDelay']; - /** - * Creates a full URL based on the route state. - * The storage adaptor maps all syncable keys to the query string of the URL. - */ - private readonly _createURL: Required< - BrowserHistoryArgs - >['createURL']; - /** - * Parses the URL into a route state. - * It should be symmetrical to `createURL`. - */ - private readonly parseURL: Required< - BrowserHistoryArgs - >['parseURL']; - /** - * Returns the location to store in the history. - * @default () => new URL(window.location.href) - */ - private readonly getCurrentURL: Required< - BrowserHistoryArgs - >['getCurrentURL']; - - private writeTimer?: ReturnType; - private _onPopState?: (event: PopStateEvent) => void; - - /** - * Indicates if last action was back/forward in the browser. - */ - private inPopState: boolean = false; - - /** - * Indicates whether the history router is disposed or not. - */ - protected isDisposed: boolean = false; - - /** - * Indicates the window.history.length before the last call to - * window.history.pushState (called in `write`). - * It allows to determine if a `pushState` has been triggered elsewhere, - * and thus to prevent the `write` method from calling `pushState`. - */ - private latestAcknowledgedHistory: number = 0; - - private _start?: (onUpdate: () => void) => void; - private _dispose?: () => void; - private _push?: (url: string) => void; - private _cleanUrlOnDispose: boolean; - - /** - * Initializes a new storage provider that syncs the search state to the URL - * using web APIs (`window.location.pushState` and `onpopstate` event). - */ - public constructor({ - windowTitle, - writeDelay = 400, - createURL, - parseURL, - getCurrentURL, - start, - dispose, - push, - cleanUrlOnDispose, - }: BrowserHistoryArgs) { - this.windowTitle = windowTitle; - this.writeTimer = undefined; - this.writeDelay = writeDelay; - this._createURL = createURL; - this.parseURL = parseURL; - this.getCurrentURL = getCurrentURL; - this._start = start; - this._dispose = dispose; - this._push = push; - this._cleanUrlOnDispose = Boolean(cleanUrlOnDispose); - - safelyRunOnBrowser(({ window }) => { - const title = this.windowTitle && this.windowTitle(this.read()); - setWindowTitle(title); - - this.latestAcknowledgedHistory = window.history.length; - }); - } - - /** - * Reads the URL and returns a syncable UI search state. - */ - public read(): TRouteState { - return this.parseURL({ qsModule: qs, currentURL: this.getCurrentURL() }); - } - - /** - * Pushes a search state into the URL. - */ - public write(routeState: TRouteState): void { - safelyRunOnBrowser(({ window }) => { - const url = this.createURL(routeState); - const title = this.windowTitle && this.windowTitle(routeState); - - if (this.writeTimer) { - clearTimeout(this.writeTimer); - } - - this.writeTimer = setTimeout(() => { - setWindowTitle(title); - - if (this.shouldWrite(url)) { - if (this._push) { - this._push(url); - } else { - window.history.pushState(routeState, title || '', url); - } - this.latestAcknowledgedHistory = window.history.length; - } - this.inPopState = false; - this.writeTimer = undefined; - }, this.writeDelay); - }); - } - - /** - * Sets a callback on the `onpopstate` event of the history API of the current page. - * It enables the URL sync to keep track of the changes. - */ - public onUpdate(callback: (routeState: TRouteState) => void): void { - if (this._start) { - this._start(() => { - callback(this.read()); - }); - } - - this._onPopState = () => { - if (this.writeTimer) { - clearTimeout(this.writeTimer); - this.writeTimer = undefined; - } - - this.inPopState = true; - - // We always read the state from the URL because the state of the history - // can be incorect in some cases (e.g. using React Router). - callback(this.read()); - }; - - safelyRunOnBrowser(({ window }) => { - window.addEventListener('popstate', this._onPopState!); - }); - } - - /** - * Creates a complete URL from a given syncable UI state. - * - * It always generates the full URL, not a relative one. - * This allows to handle cases like using a . - * See: https://github.com/algolia/instantsearch/issues/790 - */ - public createURL(routeState: TRouteState): string { - const url = this._createURL({ - qsModule: qs, - routeState, - currentURL: this.getCurrentURL(), - }); - - if (__DEV__) { - try { - // We just want to check if the URL is valid. - // eslint-disable-next-line no-new - new URL(url); - } catch (e) { - warning( - false, - `The URL returned by the \`createURL\` function is invalid. -Please make sure it returns an absolute URL to avoid issues, e.g: \`https://algolia.com/search?query=iphone\`.` - ); - } - } - - return url; - } - - /** - * Removes the event listener and cleans up the URL. - */ - public dispose(): void { - if (this._dispose) { - this._dispose(); - } - - this.isDisposed = true; - - safelyRunOnBrowser(({ window }) => { - if (this._onPopState) { - window.removeEventListener('popstate', this._onPopState); - } - }); - - if (this.writeTimer) { - clearTimeout(this.writeTimer); - } - - if (this._cleanUrlOnDispose) { - this.write({} as TRouteState); - } - } - - public start() { - this.isDisposed = false; - } - - private shouldWrite(url: string): boolean { - return safelyRunOnBrowser(({ window }) => { - // When disposed and the cleanUrlOnDispose is set to false, we do not want to write the URL. - if (this.isDisposed && !this._cleanUrlOnDispose) { - return false; - } - - // We do want to `pushState` if: - // - the router is not disposed, IS.js needs to update the URL - // OR - // - the last write was from InstantSearch.js - // (unlike a SPA, where it would have last written) - const lastPushWasByISAfterDispose = !( - this.isDisposed && - this.latestAcknowledgedHistory !== window.history.length - ); - - return ( - // When the last state change was through popstate, the IS.js state changes, - // but that should not write the URL. - !this.inPopState && - // When the previous pushState after dispose was by IS.js, we want to write the URL. - lastPushWasByISAfterDispose && - // When the URL is the same as the current one, we do not want to write it. - url !== window.location.href - ); - }); - } -} +type HistoryRouter = Router & { + isDisposed: boolean; +}; export function historyRouter({ createURL = ({ qsModule, routeState, currentURL }) => { @@ -334,16 +85,186 @@ export function historyRouter({ dispose, push, cleanUrlOnDispose, -}: Partial> = {}): BrowserHistory { - return new BrowserHistory({ - createURL, - parseURL, - writeDelay, - windowTitle, - getCurrentURL, - start, - dispose, - push, - cleanUrlOnDispose, +}: Partial> = {}) { + let writeTimer: ReturnType | undefined; + let inPopState = false; + let onPopState: (() => void) | undefined; + let latestAcknowledgedHistory = 0; + + function shouldWrite( + url: string, + router: HistoryRouter + ): boolean { + return safelyRunOnBrowser(({ window }) => { + // When disposed and the cleanUrlOnDispose is set to false, we do not want to write the URL. + if (router.isDisposed && !cleanUrlOnDispose) { + return false; + } + + // We do want to `pushState` if: + // - the router is not disposed, IS.js needs to update the URL + // OR + // - the last write was from InstantSearch.js + // (unlike a SPA, where it would have last written) + const lastPushWasByISAfterDispose = !( + router.isDisposed && latestAcknowledgedHistory !== window.history.length + ); + + return ( + // When the last state change was through popstate, the IS.js state changes, + // but that should not write the URL. + !inPopState && + // When the previous pushState after dispose was by IS.js, we want to write the URL. + lastPushWasByISAfterDispose && + // When the URL is the same as the current one, we do not want to write it. + url !== window.location.href + ); + }); + } + + const router: HistoryRouter = { + /** + * Identifier of the router. + */ + $$type: 'ais.browser', + + /** + * Whether the router has been disposed (no longer active). + */ + isDisposed: false, + + /** + * Reads the URL and returns a syncable UI search state. + */ + read(): TRouteState { + return parseURL({ qsModule: qs, currentURL: getCurrentURL() }); + }, + + /** + * Pushes a search state into the URL. + */ + write(routeState: TRouteState): void { + safelyRunOnBrowser(({ window }) => { + const url = this.createURL(routeState); + const title = windowTitle && windowTitle(routeState); + + if (writeTimer) { + clearTimeout(writeTimer); + } + + writeTimer = setTimeout(() => { + setWindowTitle(title); + + if (shouldWrite(url, router)) { + if (push) { + push(url); + } else { + window.history.pushState(routeState, title || '', url); + } + latestAcknowledgedHistory = window.history.length; + } + inPopState = false; + writeTimer = undefined; + }, writeDelay); + }); + }, + + /** + * Sets a callback on the `onpopstate` event of the history API of the current page. + * It enables the URL sync to keep track of the changes. + */ + onUpdate(callback: (routeState: TRouteState) => void): void { + if (start) { + start(() => { + callback(this.read()); + }); + } + + onPopState = () => { + if (writeTimer) { + clearTimeout(writeTimer); + writeTimer = undefined; + } + + inPopState = true; + + // We always read the state from the URL because the state of the history + // can be incorrect in some cases (e.g. using React Router). + callback(this.read()); + }; + + safelyRunOnBrowser(({ window }) => { + window.addEventListener('popstate', onPopState!); + }); + }, + + /** + * Creates a complete URL from a given syncable UI state. + * + * It always generates the full URL, not a relative one. + * This allows to handle cases like using a . + * See: https://github.com/algolia/instantsearch/issues/790 + */ + createURL(routeState: TRouteState): string { + const url = createURL({ + qsModule: qs, + routeState, + currentURL: getCurrentURL(), + }); + + if (__DEV__) { + try { + // We just want to check if the URL is valid. + // eslint-disable-next-line no-new + new URL(url); + } catch (e) { + warning( + false, + `The URL returned by the \`createURL\` function is invalid. +Please make sure it returns an absolute URL to avoid issues, e.g: \`https://algolia.com/search?query=iphone\`.` + ); + } + } + + return url; + }, + + /** + * Removes the event listener and cleans up the URL. + */ + dispose(): void { + if (dispose) { + dispose(); + } + + this.isDisposed = true; + + safelyRunOnBrowser(({ window }) => { + if (onPopState) { + window.removeEventListener('popstate', onPopState); + } + }); + + if (writeTimer) { + clearTimeout(writeTimer); + } + + if (cleanUrlOnDispose) { + this.write({} as TRouteState); + } + }, + + start() { + this.isDisposed = false; + }, + }; + + safelyRunOnBrowser(({ window }) => { + const title = windowTitle && windowTitle(router.read()); + setWindowTitle(title); + + latestAcknowledgedHistory = window.history.length; }); + + return router; }