diff --git a/.circleci/config.yml b/.circleci/config.yml index a2c9fa7e30..84e2a32f6e 100644 --- a/.circleci/config.yml +++ b/.circleci/config.yml @@ -124,6 +124,21 @@ jobs: command: | yarn shipjs trigger + 'prepare release': + <<: *defaults + steps: + - checkout + - run: *install_yarn_version + - restore_cache: *restore_yarn_cache + - run: *run_yarn_install + - save_cache: *save_yarn_cache + - run: + name: Prepare a pull request for next release + command: | + git config --global user.email "instantsearch-bot@algolia.com" + git config --global user.name "InstantSearch" + yarn run release --yes --no-browse + workflows: version: 2 ci: @@ -134,4 +149,19 @@ workflows: - type check algoliasearch v3 - type check js (optional) - e2e tests - - release if needed + - 'release if needed': + filters: + branches: + only: + - master + - next + 'scheduled release': + triggers: + - schedule: + cron: '0 9 * * 2' + filters: + branches: + only: + - master + jobs: + - prepare release diff --git a/babel.config.js b/babel.config.js index 4edd769ae3..ff00636983 100644 --- a/babel.config.js +++ b/babel.config.js @@ -26,7 +26,13 @@ module.exports = api => { const buildPlugins = clean([ '@babel/plugin-proposal-class-properties', '@babel/plugin-transform-react-constant-elements', - 'babel-plugin-transform-react-remove-prop-types', + [ + 'babel-plugin-transform-react-remove-prop-types', + { + mode: 'remove', + removeImport: true, + }, + ], 'babel-plugin-transform-react-pure-class-to-function', wrapWarningWithDevCheck, (isCJS || isES) && [ diff --git a/package.json b/package.json index c1cc0b5dd9..3cd34f964f 100644 --- a/package.json +++ b/package.json @@ -135,7 +135,7 @@ "scriptjs": "2.5.9", "semver": "6.3.0", "shelljs": "0.8.3", - "shipjs": "0.16.0", + "shipjs": "0.21.0", "typescript": "3.8.3", "webpack": "4.41.5" }, diff --git a/ship.config.js b/ship.config.js index 30c76eca6d..4862e2a863 100644 --- a/ship.config.js +++ b/ship.config.js @@ -3,7 +3,6 @@ const fs = require('fs'); const path = require('path'); module.exports = { - mergeStrategy: { toSameBranch: ['master', 'next'] }, shouldPrepare: ({ releaseType, commitNumbersPerType }) => { const { fix = 0 } = commitNumbersPerType; if (releaseType === 'patch' && fix === 0) { @@ -20,10 +19,9 @@ module.exports = { beforeCommitChanges: ({ exec }) => { exec('yarn doctoc'); }, - pullRequestTeamReviewer: ['instantsearch-for-websites'], + pullRequestTeamReviewers: ['instantsearch-for-websites'], buildCommand: ({ version }) => `NODE_ENV=production VERSION=${version} yarn build`, - testCommandBeforeRelease: () => 'echo "No need to test again."', afterPublish: ({ exec, version, releaseTag }) => { if (releaseTag === 'latest' && version.startsWith('4.')) { exec('./scripts/release/build-experimental-typescript.js'); @@ -33,10 +31,9 @@ module.exports = { } }, slack: { - // disable slack notification for `prepared` and `releaseStart` lifecycle. + // disable slack notification for `prepared` lifecycle. // Ship.js will send slack message only for `releaseSuccess`. prepared: null, - releaseStart: null, releaseSuccess: ({ appName, version, diff --git a/src/lib/__tests__/RoutingManager-test.ts b/src/lib/__tests__/RoutingManager-test.ts index cf513223c1..571f6b208d 100644 --- a/src/lib/__tests__/RoutingManager-test.ts +++ b/src/lib/__tests__/RoutingManager-test.ts @@ -313,12 +313,9 @@ describe('RoutingManager', () => { await runAllMicroTasks(); - expect(router.write).toHaveBeenCalledTimes(2); - expect(router.write).toHaveBeenLastCalledWith({ - indexName: { - query: 'Apple', - }, - }); + // 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 first render', async () => { @@ -363,12 +360,9 @@ describe('RoutingManager', () => { await runAllMicroTasks(); - expect(router.write).toHaveBeenCalledTimes(2); - expect(router.write).toHaveBeenLastCalledWith({ - indexName: { - query: 'Apple iPhone', - }, - }); + // 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 () => { @@ -443,6 +437,85 @@ describe('RoutingManager', () => { }, }); }); + + 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: any = createFakeSearchBox(); + const fakeHitsPerPage1 = createFakeHitsPerPage(); + const fakeHitsPerPage2 = createFakeHitsPerPage(); + + search.addWidgets([fakeSearchBox, fakeHitsPerPage1, fakeHitsPerPage2]); + + search.start(); + + await runAllMicroTasks(); + + // Trigger an update - push a change + fakeSearchBox.refine('Apple'); + + expect(router.write).toHaveBeenCalledTimes(1); + expect(router.write).toHaveBeenLastCalledWith({ + indexName: { + query: 'Apple', + }, + }); + + // Trigger change without UI state change + search.removeWidgets([fakeHitsPerPage1]); + + expect(router.write).toHaveBeenCalledTimes(1); + + await runAllMicroTasks(); + + triggerChange = true; + // Trigger change without UI state change but with a route change + search.removeWidgets([fakeHitsPerPage2]); + + expect(router.write).toHaveBeenCalledTimes(2); + expect(router.write).toHaveBeenLastCalledWith({ + indexName: { + query: 'Apple', + triggerChange: true, + }, + }); + }); }); describe('windowTitle', () => { diff --git a/src/lib/routers/__tests__/history.test.ts b/src/lib/routers/__tests__/history.test.ts index 187d91a954..66dcda8d34 100644 --- a/src/lib/routers/__tests__/history.test.ts +++ b/src/lib/routers/__tests__/history.test.ts @@ -8,111 +8,6 @@ describe('life cycle', () => { jest.restoreAllMocks(); }); - it('does not write the same url twice', async () => { - const pushState = jest.spyOn(window.history, 'pushState'); - const router = historyRouter({ - writeDelay: 0, - }); - - router.write({ some: 'state' }); - await wait(0); - - router.write({ some: 'state' }); - await wait(0); - - router.write({ some: 'state' }); - await wait(0); - - expect(pushState).toHaveBeenCalledTimes(1); - expect(pushState.mock.calls).toMatchInlineSnapshot(` - Array [ - Array [ - Object { - "some": "state", - }, - "", - "http://localhost/?some=state", - ], - ] - `); - }); - - it('does not write if already externally updated to desired URL', async () => { - const pushState = jest.spyOn(window.history, 'pushState'); - const router = historyRouter({ - writeDelay: 0, - }); - - const fakeState = { identifier: 'fake state' }; - - router.write({ some: 'state one' }); - - // external update before timeout passes - window.history.pushState( - fakeState, - '', - 'http://localhost/?some=state%20two' - ); - - // this write isn't needed anymore - router.write({ some: 'state two' }); - await wait(0); - - expect(pushState).toHaveBeenCalledTimes(1); - expect(pushState.mock.calls).toMatchInlineSnapshot(` - Array [ - Array [ - Object { - "identifier": "fake state", - }, - "", - "http://localhost/?some=state%20two", - ], - ] - `); - - // proves that InstantSearch' write did not happen - expect(history.state).toBe(fakeState); - }); - - it('does not write the same url title twice', async () => { - const title = jest.spyOn(window.document, 'title', 'set'); - const pushState = jest.spyOn(window.history, 'pushState'); - - const router = historyRouter({ - writeDelay: 0, - windowTitle: state => `My Site - ${state.some}`, - }); - - expect(title).toHaveBeenCalledTimes(1); - expect(window.document.title).toBe('My Site - undefined'); - - router.write({ some: 'state' }); - await wait(0); - - router.write({ some: 'state' }); - await wait(0); - - router.write({ some: 'state' }); - await wait(0); - - expect(pushState).toHaveBeenCalledTimes(1); - expect(pushState.mock.calls).toMatchInlineSnapshot(` - Array [ - Array [ - Object { - "some": "state", - }, - "My Site - state", - "http://localhost/?some=state", - ], - ] - `); - - expect(title).toHaveBeenCalledTimes(2); - expect(window.document.title).toBe('My Site - state'); - }); - it('writes after timeout is done', async () => { const pushState = jest.spyOn(window.history, 'pushState'); diff --git a/src/lib/routers/history.ts b/src/lib/routers/history.ts index ffb4dbeeb6..582a44069d 100644 --- a/src/lib/routers/history.ts +++ b/src/lib/routers/history.ts @@ -128,11 +128,8 @@ class BrowserHistory implements Router { } this.writeTimer = window.setTimeout(() => { - if (window.location.href !== url) { - setWindowTitle(title); - - window.history.pushState(routeState, title || '', url); - } + setWindowTitle(title); + window.history.pushState(routeState, title || '', url); this.writeTimer = undefined; }, this.writeDelay); } diff --git a/src/middlewares/createRouter.ts b/src/middlewares/createRouter.ts index b2605a3287..3045b1595c 100644 --- a/src/middlewares/createRouter.ts +++ b/src/middlewares/createRouter.ts @@ -1,7 +1,14 @@ import simpleStateMapping from '../lib/stateMappings/simple'; import historyRouter from '../lib/routers/history'; import { Index } from '../widgets/index/index'; -import { Router, StateMapping, UiState, Middleware } from '../types'; +import { + Router, + StateMapping, + UiState, + Middleware, + RouteState, +} from '../types'; +import { isEqual } from '../lib/utils'; const walk = (current: Index, callback: (index: Index) => void) => { callback(current); @@ -49,11 +56,19 @@ export const createRouter: RoutingManager = (props = {}) => { ...stateMapping.routeToState(router.read()), }; + let lastRouteState: RouteState | undefined = undefined; + return { onStateChange({ uiState }) { - const route = stateMapping.stateToRoute(uiState); - - router.write(route); + const routeState = stateMapping.stateToRoute(uiState); + + if ( + lastRouteState === undefined || + !isEqual(lastRouteState, routeState) + ) { + router.write(routeState); + lastRouteState = routeState; + } }, subscribe() { diff --git a/src/widgets/refinement-list/__tests__/__snapshots__/refinement-list-test.js.snap b/src/widgets/refinement-list/__tests__/__snapshots__/refinement-list-test.js.snap index 437f540065..d7f72925ce 100644 --- a/src/widgets/refinement-list/__tests__/__snapshots__/refinement-list-test.js.snap +++ b/src/widgets/refinement-list/__tests__/__snapshots__/refinement-list-test.js.snap @@ -63,29 +63,29 @@ Object { {{#helpers.formatNumber}}{{count}}{{/helpers.formatNumber}} ", "loadingIndicator": " - - - - - - - - + + + + + + + - - ", + + + ", "reset": " - - - - ", + + + + ", "searchableNoResults": "No results", "showMoreText": " {{#isShowingMore}} @@ -96,10 +96,10 @@ Object { {{/isShowingMore}} ", "submit": " - - - - ", + + + + ", }, "templatesConfig": Object {}, "useCustomCompileOptions": Object { diff --git a/src/widgets/refinement-list/defaultTemplates.js b/src/widgets/refinement-list/defaultTemplates.js index 71bfdcfc77..4f8366a5ab 100644 --- a/src/widgets/refinement-list/defaultTemplates.js +++ b/src/widgets/refinement-list/defaultTemplates.js @@ -1,3 +1,5 @@ +import searchBoxTemplates from '../search-box/defaultTemplates'; + export default { item: `