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: `