diff --git a/src/lib/__tests__/routing-test.ts b/src/lib/__tests__/routing-test.ts new file mode 100644 index 0000000000..6f0f391ba1 --- /dev/null +++ b/src/lib/__tests__/routing-test.ts @@ -0,0 +1,189 @@ +import { createSearchClient } from '../../../test/mock/createSearchClient'; +import { wait } from '../../../test/utils/wait'; +import historyRouter from '../routers/history'; +import instantsearch from '../..'; +import { connectSearchBox } from '../../connectors'; + +const writeDelay = 10; +const writeWait = 1.5 * writeDelay; + +// This test may tear and not execute the tests in the right order. +// It seems to be related to a timing issue but we are not sure. + +describe('routing', () => { + beforeEach(() => { + window.history.pushState({}, '', 'http://localhost/'); + jest.clearAllMocks(); + }); + + describe('writeOnDispose=true', () => { + test('cleans URL on dispose', async () => { + const pushState = jest.spyOn(window.history, 'pushState'); + + const search = instantsearch({ + indexName: 'indexName', + searchClient: createSearchClient(), + routing: { + router: historyRouter({ + writeDelay, + }), + }, + }); + + search.addWidgets([connectSearchBox(() => {})({})]); + + search.start(); + + // Check URL has been initialized + await wait(writeWait); + expect(window.location.search).toEqual(''); + expect(pushState).toHaveBeenCalledTimes(0); + + // Trigger an update - push a change + search.renderState.indexName!.searchBox!.refine('Apple'); + + // Check URL has been updated + await wait(writeWait); + expect(window.location.search).toEqual( + `?${encodeURI('indexName[query]=Apple')}` + ); + expect(pushState).toHaveBeenCalledTimes(1); + + // Trigger a dispose + search.dispose(); + + // Check URL has been cleaned + await wait(writeWait); + expect(window.location.search).toEqual(''); + expect(pushState).toHaveBeenCalledTimes(2); + }); + + test('refine after dispose', async () => { + const pushState = jest.spyOn(window.history, 'pushState'); + + const search = instantsearch({ + indexName: 'indexName', + searchClient: createSearchClient(), + routing: { + router: historyRouter({ + writeDelay, + }), + }, + }); + + search.addWidgets([connectSearchBox(() => {})({})]); + search.start(); + + // Trigger an update - push a change + search.renderState.indexName!.searchBox!.refine('Apple'); + + // Trigger a dispose + search.dispose(); + + // Trigger an update - push a change + search.renderState.indexName!.searchBox!.refine('Apple'); + + // Check URL has not been updated + await wait(writeWait); + expect(window.location.search).toEqual(''); + expect(pushState).toHaveBeenCalledTimes(1); + }); + + test('URL is updated after starting instantsearch again', async () => { + const pushState = jest.spyOn(window.history, 'pushState'); + + const search = instantsearch({ + indexName: 'indexName', + searchClient: createSearchClient(), + routing: { + router: historyRouter({ + writeDelay, + }), + }, + }); + + search.addWidgets([connectSearchBox(() => {})({})]); + + search.start(); + + // Trigger an update - push a change + search.renderState.indexName!.searchBox!.refine('Query'); + + // Check URL has been updated + await wait(writeWait); + expect(window.location.search).toEqual( + `?${encodeURI('indexName[query]=Query')}` + ); + expect(pushState).toHaveBeenCalledTimes(1); + + // Trigger a dispose + search.dispose(); + + // Check URL has been cleaned + await wait(writeWait); + expect(window.location.search).toEqual(''); + expect(pushState).toHaveBeenCalledTimes(2); + + // Start again + search.addWidgets([connectSearchBox(() => {})({})]); + + search.start(); + + // Trigger an update - push a change + search.renderState.indexName!.searchBox!.refine('Test'); + + // Check URL has been updated + await wait(writeWait); + expect(window.location.search).toEqual( + `?${encodeURI('indexName[query]=Test')}` + ); + expect(pushState).toHaveBeenCalledTimes(3); + }); + }); + + describe('writeOnDispose=false', () => { + test('does not clean URL on dispose', async () => { + const pushState = jest.spyOn(window.history, 'pushState'); + + const search = instantsearch({ + indexName: 'indexName', + searchClient: createSearchClient(), + routing: { + router: historyRouter({ + writeDelay, + writeOnDispose: false, + }), + }, + }); + + search.addWidgets([connectSearchBox(() => {})({})]); + + search.start(); + + // Check URL has been initialized + await wait(writeWait); + expect(window.location.search).toEqual(''); + expect(pushState).toHaveBeenCalledTimes(0); + + // Trigger an update - push a change + search.renderState.indexName!.searchBox!.refine('Apple'); + + // Check URL has been updated + await wait(writeWait); + expect(window.location.search).toEqual( + `?${encodeURI('indexName[query]=Apple')}` + ); + expect(pushState).toHaveBeenCalledTimes(1); + + // Trigger a dispose + search.dispose(); + + // Check URL has not been cleaned + await wait(writeWait); + expect(window.location.search).toEqual( + `?${encodeURI('indexName[query]=Apple')}` + ); + expect(pushState).toHaveBeenCalledTimes(1); + }); + }); +}); diff --git a/src/lib/routers/history.ts b/src/lib/routers/history.ts index e0768a8c14..aea003ba4d 100644 --- a/src/lib/routers/history.ts +++ b/src/lib/routers/history.ts @@ -22,6 +22,7 @@ type BrowserHistoryArgs = { // so we should accept a subset of it that is easier to work with in any // environments. getLocation(): Location; + writeOnDispose?: boolean; }; const setWindowTitle = (title?: string): void => { @@ -78,6 +79,15 @@ class BrowserHistory implements Router { */ private shouldPushState: boolean = true; + /** + * Indicates if `write` should be called on `dispose`. + * When using other client-side routing utilities to navigate between pages + * (e.g., a front-end SPA routing library), set this option to `false`. + * + * @default true + */ + private writeOnDispose: boolean = true; + /** * Initializes a new storage provider that syncs the search state to the URL * using web APIs (`window.location.pushState` and `onpopstate` event). @@ -88,6 +98,7 @@ class BrowserHistory implements Router { createURL, parseURL, getLocation, + writeOnDispose = true, }: BrowserHistoryArgs) { this.windowTitle = windowTitle; this.writeTimer = undefined; @@ -95,6 +106,7 @@ class BrowserHistory implements Router { this._createURL = createURL; this.parseURL = parseURL; this.getLocation = getLocation; + this.writeOnDispose = writeOnDispose; safelyRunOnBrowser(() => { const title = this.windowTitle && this.windowTitle(this.read()); @@ -190,7 +202,11 @@ class BrowserHistory implements Router { clearTimeout(this.writeTimer); } - this.write({} as TRouteState); + if (this.writeOnDispose) { + this.write({} as TRouteState); + } else { + this.shouldPushState = false; + } } } @@ -231,6 +247,7 @@ export default function historyRouter({ }, }); }, + writeOnDispose, }: Partial> = {}): BrowserHistory { return new BrowserHistory({ createURL, @@ -238,5 +255,6 @@ export default function historyRouter({ writeDelay, windowTitle, getLocation, + writeOnDispose, }); } diff --git a/src/middlewares/createRouterMiddleware.ts b/src/middlewares/createRouterMiddleware.ts index 2747b991eb..6fde39f18d 100644 --- a/src/middlewares/createRouterMiddleware.ts +++ b/src/middlewares/createRouterMiddleware.ts @@ -62,13 +62,16 @@ export const createRouterMiddleware = < const initialUiState = instantSearchInstance._initialUiState; + let subscribed = false; + return { onStateChange({ uiState }) { const routeState = stateMapping.stateToRoute(uiState); if ( - lastRouteState === undefined || - !isEqual(lastRouteState, routeState) + (lastRouteState === undefined || + !isEqual(lastRouteState, routeState)) && + subscribed ) { router.write(routeState); lastRouteState = routeState; @@ -76,6 +79,8 @@ export const createRouterMiddleware = < }, subscribe() { + subscribed = true; + instantSearchInstance._initialUiState = { ...initialUiState, ...stateMapping.routeToState(router.read()), @@ -87,6 +92,8 @@ export const createRouterMiddleware = < }, unsubscribe() { + subscribed = false; + router.dispose(); }, };