diff --git a/package.json b/package.json index 0a2d648903..1cd8ecc2f4 100644 --- a/package.json +++ b/package.json @@ -69,7 +69,7 @@ "conventional-changelog-cli": "2.0.12", "copy-webpack-plugin": "5.0.2", "css-loader": "2.1.1", - "enzyme": "3.9.0", + "enzyme": "3.10.0", "enzyme-adapter-react-16": "1.12.1", "enzyme-to-json": "3.3.5", "eslint": "5.16.0", diff --git a/packages/react-instantsearch-core/src/widgets/InstantSearch.tsx b/packages/react-instantsearch-core/src/widgets/InstantSearch.tsx index 8a24a8e465..ce7ddcf735 100644 --- a/packages/react-instantsearch-core/src/widgets/InstantSearch.tsx +++ b/packages/react-instantsearch-core/src/widgets/InstantSearch.tsx @@ -4,16 +4,8 @@ import createInstantSearchManager from '../core/createInstantSearchManager'; import { InstantSearchProvider, InstantSearchContext } from '../core/context'; import { Store } from '../core/createStore'; -function validateNextProps(props, nextProps) { - if (!props.searchState && nextProps.searchState) { - throw new Error( - "You can't switch from being uncontrolled to controlled" - ); - } else if (props.searchState && !nextProps.searchState) { - throw new Error( - "You can't switch from being controlled to uncontrolled" - ); - } +function isControlled(props: Props) { + return Boolean(props.searchState); } // @TODO: move this to the helper? @@ -137,64 +129,74 @@ class InstantSearch extends Component { stalledSearchDelay: PropTypes.number, }; - isControlled: boolean; - isUnmounting: boolean; - aisManager: InstantSearchManager; - - constructor(props: Props) { - super(props); - this.isControlled = Boolean(props.searchState); - const initialState = this.isControlled ? props.searchState : {}; - this.isUnmounting = false; - - this.aisManager = createInstantSearchManager({ - indexName: props.indexName, - searchClient: props.searchClient, - initialState, - resultsState: props.resultsState, - stalledSearchDelay: props.stalledSearchDelay, - }); - - this.state = { + static getDerivedStateFromProps( + nextProps: Props, + prevState: State + ): Partial { + return { contextValue: { - onInternalStateUpdate: this.onWidgetsInternalStateUpdate.bind(this), - createHrefForState: this.createHrefForState.bind(this), - onSearchForFacetValues: this.onSearchForFacetValues.bind(this), - onSearchStateChange: this.onSearchStateChange.bind(this), - onSearchParameters: this.onSearchParameters.bind(this), - store: this.aisManager.store, - widgetsManager: this.aisManager.widgetsManager, - mainTargetedIndex: this.props.indexName, + ...prevState.contextValue, + mainTargetedIndex: nextProps.indexName, }, }; } - componentWillReceiveProps(nextProps) { - // @TODO: DidUpdate - validateNextProps(this.props, nextProps); - - if (this.props.indexName !== nextProps.indexName) { - this.aisManager.updateIndex(nextProps.indexName); - this.setState(state => ({ - contextValue: { - ...state.contextValue, - mainTargetedIndex: nextProps.indexName, - }, - })); + isUnmounting: boolean = false; + aisManager: InstantSearchManager = createInstantSearchManager({ + indexName: this.props.indexName, + searchClient: this.props.searchClient, + initialState: this.props.searchState || {}, + resultsState: this.props.resultsState, + stalledSearchDelay: this.props.stalledSearchDelay, + }); + isControlled: boolean = isControlled(this.props); + + state = { + contextValue: { + onInternalStateUpdate: this.onWidgetsInternalStateUpdate.bind(this), + createHrefForState: this.createHrefForState.bind(this), + onSearchForFacetValues: this.onSearchForFacetValues.bind(this), + onSearchStateChange: this.onSearchStateChange.bind(this), + onSearchParameters: this.onSearchParameters.bind(this), + store: this.aisManager.store, + widgetsManager: this.aisManager.widgetsManager, + mainTargetedIndex: this.props.indexName, + }, + }; + + componentDidUpdate(prevProps: Props) { + const nextIsControlled = isControlled(this.props); + + if (!this.isControlled && nextIsControlled) { + throw new Error( + "You can't switch from being uncontrolled to controlled" + ); } - if (this.props.refresh !== nextProps.refresh) { - if (nextProps.refresh) { - this.aisManager.clearCache(); - } + if (this.isControlled && !nextIsControlled) { + throw new Error( + "You can't switch from being controlled to uncontrolled" + ); } - if (this.props.searchClient !== nextProps.searchClient) { - this.aisManager.updateClient(nextProps.searchClient); + /* + * Clear cache when `refresh` changes to `true`. + * Prevents users to always clear the cache on render if they forget to revert it to `false`. + */ + if (this.props.refresh !== prevProps.refresh && this.props.refresh) { + this.aisManager.clearCache(); } if (this.isControlled) { - this.aisManager.onExternalStateUpdate(nextProps.searchState); + this.aisManager.onExternalStateUpdate(this.props.searchState); + } + + if (prevProps.indexName !== this.props.indexName) { + this.aisManager.updateIndex(this.props.indexName); + } + + if (prevProps.searchClient !== this.props.searchClient) { + this.aisManager.updateClient(this.props.searchClient); } } @@ -247,10 +249,10 @@ class InstantSearch extends Component { } render() { - const childrenCount = Children.count(this.props.children); - if (childrenCount === 0) { + if (Children.count(this.props.children) === 0) { return null; } + return ( {this.props.children} diff --git a/packages/react-instantsearch-core/src/widgets/__tests__/InstantSearch.js b/packages/react-instantsearch-core/src/widgets/__tests__/InstantSearch.js index eaaeaea556..f5b4073772 100644 --- a/packages/react-instantsearch-core/src/widgets/__tests__/InstantSearch.js +++ b/packages/react-instantsearch-core/src/widgets/__tests__/InstantSearch.js @@ -7,9 +7,23 @@ import { InstantSearchConsumer } from '../../core/context'; Enzyme.configure({ adapter: new Adapter() }); +const createFakeInstantSearchManager = (rest = {}) => ({ + context: {}, + updateIndex: jest.fn(() => {}), + updateClient: jest.fn(() => {}), + ...rest, +}); + +const createFakeSearchClient = (rest = {}) => ({ + search: jest.fn(() => {}), + ...rest, +}); + jest.mock('../../core/createInstantSearchManager', () => jest.fn(() => ({ context: {}, + updateIndex: () => {}, + updateClient: () => {}, })) ); @@ -17,7 +31,7 @@ const DEFAULT_PROPS = { appId: 'foo', apiKey: 'bar', indexName: 'foobar', - searchClient: {}, + searchClient: createFakeSearchClient(), root: { Root: 'div', }, @@ -115,15 +129,15 @@ describe('InstantSearch', () => { expect(createInstantSearchManager.mock.calls[0][0]).toEqual({ indexName: DEFAULT_PROPS.indexName, initialState: {}, - searchClient: {}, + searchClient: DEFAULT_PROPS.searchClient, stalledSearchDelay: 200, }); }); it('updates Algolia client when new one is given in props', () => { - const ism = { + const ism = createFakeInstantSearchManager({ updateClient: jest.fn(), - }; + }); createInstantSearchManager.mockImplementation(() => ism); @@ -133,20 +147,21 @@ describe('InstantSearch', () => { ); - expect(ism.updateClient.mock.calls).toHaveLength(0); + expect(ism.updateClient).toHaveBeenCalledTimes(0); + wrapper.setProps({ ...DEFAULT_PROPS, - searchClient: {}, + searchClient: createFakeSearchClient(), }); - expect(ism.updateClient.mock.calls).toHaveLength(1); + expect(ism.updateClient).toHaveBeenCalledTimes(1); }); it('works as a controlled input', () => { - const ism = { + const ism = createFakeInstantSearchManager({ transitionState: searchState => ({ ...searchState, transitioned: true }), onExternalStateUpdate: jest.fn(), - }; + }); createInstantSearchManager.mockImplementation(() => ism); const initialState = { a: 0 }; const onSearchStateChange = jest.fn(searchState => { @@ -187,10 +202,10 @@ describe('InstantSearch', () => { }); it('works as an uncontrolled input', () => { - const ism = { + const ism = createFakeInstantSearchManager({ transitionState: searchState => ({ ...searchState, transitioned: true }), onExternalStateUpdate: jest.fn(), - }; + }); createInstantSearchManager.mockImplementation(() => ism); const wrapper = mount( @@ -226,10 +241,10 @@ describe('InstantSearch', () => { }); it("exposes the isManager's store and widgetsManager in context", () => { - const ism = { + const ism = createFakeInstantSearchManager({ store: {}, widgetsManager: {}, - }; + }); createInstantSearchManager.mockImplementation(() => ism); let childContext = false; mount( @@ -248,9 +263,9 @@ describe('InstantSearch', () => { }); it('onSearchStateChange should not be called and search should be skipped if the widget is unmounted', () => { - const ism = { + const ism = createFakeInstantSearchManager({ skipSearch: jest.fn(), - }; + }); let childContext; createInstantSearchManager.mockImplementation(() => ism); const onSearchStateChangeMock = jest.fn(); @@ -271,18 +286,18 @@ describe('InstantSearch', () => { wrapper.unmount(); childContext.onSearchStateChange({}); - expect(onSearchStateChangeMock.mock.calls).toHaveLength(0); - expect(ism.skipSearch.mock.calls).toHaveLength(1); + expect(onSearchStateChangeMock).toHaveBeenCalledTimes(0); + expect(ism.skipSearch).toHaveBeenCalledTimes(1); }); it('refreshes the cache when the refresh prop is set to true', () => { - const ism = { + const ism = createFakeInstantSearchManager({ clearCache: jest.fn(), - }; + }); createInstantSearchManager.mockImplementation(() => ism); - const wrapper = shallow( + const wrapper = mount(
@@ -305,14 +320,43 @@ describe('InstantSearch', () => { expect(ism.clearCache).toHaveBeenCalledTimes(1); }); + it('refreshes the cache only once if the refresh prop stay to true', () => { + const ism = createFakeInstantSearchManager({ + clearCache: jest.fn(), + }); + + createInstantSearchManager.mockImplementation(() => ism); + + const wrapper = mount( + +
+ + ); + + expect(ism.clearCache).not.toHaveBeenCalled(); + + wrapper.setProps({ + ...DEFAULT_PROPS, + refresh: true, + }); + + expect(ism.clearCache).toHaveBeenCalledTimes(1); + + wrapper.setProps({ + indexName: DEFAULT_PROPS.indexName, + }); + + expect(ism.clearCache).toHaveBeenCalledTimes(1); + }); + it('updates the index when the the index changes', () => { - const ism = { + const ism = createFakeInstantSearchManager({ updateIndex: jest.fn(), - }; + }); createInstantSearchManager.mockImplementation(() => ism); - const wrapper = shallow( + const wrapper = mount( {contextValue => contextValue.mainTargetedIndex} @@ -320,30 +364,32 @@ describe('InstantSearch', () => { ); - expect(ism.updateIndex).not.toHaveBeenCalled(); + expect(wrapper.text()).toMatchInlineSnapshot(`"foobar"`); + // setting the same prop wrapper.setProps({ indexName: 'foobar', }); - expect(wrapper.html()).toMatchInlineSnapshot(`"foobar"`); - expect(ism.updateIndex).not.toHaveBeenCalled(); + expect(wrapper.text()).toMatchInlineSnapshot(`"foobar"`); + + // changing the prop wrapper.setProps({ indexName: 'newIndexName', }); - expect(wrapper.html()).toMatchInlineSnapshot(`"newIndexName"`); + expect(ism.updateIndex).toHaveBeenCalledWith('newIndexName'); - expect(ism.updateIndex).toHaveBeenCalledTimes(1); + expect(wrapper.text()).toMatchInlineSnapshot(`"newIndexName"`); }); it('calls onSearchParameters with the right values if function provided', () => { - const ism = { + const ism = createFakeInstantSearchManager({ store: {}, widgetsManager: {}, - }; + }); createInstantSearchManager.mockImplementation(() => ism); const onSearchParametersMock = jest.fn(); const getSearchParameters = jest.fn(); @@ -366,7 +412,7 @@ describe('InstantSearch', () => { childContext.onSearchParameters(getSearchParameters, context, props); - expect(onSearchParametersMock.mock.calls).toHaveLength(1); + expect(onSearchParametersMock).toHaveBeenCalledTimes(1); expect(onSearchParametersMock.mock.calls[0][0]).toBe(getSearchParameters); expect(onSearchParametersMock.mock.calls[0][1]).toEqual(context); expect(onSearchParametersMock.mock.calls[0][2]).toEqual(props); @@ -389,7 +435,7 @@ describe('InstantSearch', () => { childContext.onSearchParameters(getSearchParameters, context, props); - expect(onSearchParametersMock.mock.calls).toHaveLength(2); + expect(onSearchParametersMock).toHaveBeenCalledTimes(2); expect(onSearchParametersMock.mock.calls[1][3]).toEqual({ search: 'state', }); @@ -407,19 +453,19 @@ describe('InstantSearch', () => { childContext.onSearchParameters(getSearchParameters, context, props); - expect(onSearchParametersMock.mock.calls).toHaveLength(2); + expect(onSearchParametersMock).toHaveBeenCalledTimes(2); }); describe('createHrefForState', () => { it('passes through to createURL when it is defined', () => { const widgetsIds = []; - const ism = { + const ism = createFakeInstantSearchManager({ transitionState: searchState => ({ ...searchState, transitioned: true, }), getWidgetsIds: () => widgetsIds, - }; + }); createInstantSearchManager.mockImplementation(() => ism); const createURL = jest.fn(searchState => searchState); @@ -465,9 +511,9 @@ describe('InstantSearch', () => { }); it('search for facet values should be called if triggered', () => { - const ism = { + const ism = createFakeInstantSearchManager({ onSearchForFacetValues: jest.fn(), - }; + }); createInstantSearchManager.mockImplementation(() => ism); let childContext; mount( diff --git a/yarn.lock b/yarn.lock index 6a2216f4f2..a65c23dc32 100644 --- a/yarn.lock +++ b/yarn.lock @@ -7240,10 +7240,10 @@ enzyme-to-json@3.3.5: dependencies: lodash "^4.17.4" -enzyme@3.9.0: - version "3.9.0" - resolved "https://registry.yarnpkg.com/enzyme/-/enzyme-3.9.0.tgz#2b491f06ca966eb56b6510068c7894a7e0be3909" - integrity sha512-JqxI2BRFHbmiP7/UFqvsjxTirWoM1HfeaJrmVSZ9a1EADKkZgdPcAuISPMpoUiHlac9J4dYt81MC5BBIrbJGMg== +enzyme@3.10.0: + version "3.10.0" + resolved "https://registry.yarnpkg.com/enzyme/-/enzyme-3.10.0.tgz#7218e347c4a7746e133f8e964aada4a3523452f6" + integrity sha512-p2yy9Y7t/PFbPoTvrWde7JIYB2ZyGC+NgTNbVEGvZ5/EyoYSr9aG/2rSbVvyNvMHEhw9/dmGUJHWtfQIEiX9pg== dependencies: array.prototype.flat "^1.2.1" cheerio "^1.0.0-rc.2"