diff --git a/src/components/navigation/www/navigation.jsx b/src/components/navigation/www/navigation.jsx index 515b29b82..39e69c242 100644 --- a/src/components/navigation/www/navigation.jsx +++ b/src/components/navigation/www/navigation.jsx @@ -27,56 +27,62 @@ class Navigation extends React.Component { super(props); bindAll(this, [ 'getProfileUrl', - 'handleSearchSubmit' + 'handleSearchSubmit', + 'pollForMessages' ]); - this.state = { - messageCountIntervalId: -1 // javascript method interval id for getting messsage count. - }; + // Keep the timeout id so we can cancel it (e.g. when we unmount) + this.messageCountTimeoutId = -1; } componentDidMount () { if (this.props.user) { - const intervalId = setInterval(() => { - this.props.getMessageCount(this.props.user.username); - }, 120000); // check for new messages every 2 mins. - this.setState({ // eslint-disable-line react/no-did-mount-set-state - messageCountIntervalId: intervalId - }); + // Setup polling for messages to start in 2 minutes. + const twoMinInMs = 2 * 60 * 1000; + this.messageCountTimeoutId = setTimeout(this.pollForMessages.bind(this, twoMinInMs), twoMinInMs); } } componentDidUpdate (prevProps) { if (prevProps.user !== this.props.user) { this.props.handleCloseAccountNav(); if (this.props.user) { - const intervalId = setInterval(() => { - this.props.getMessageCount(this.props.user.username); - }, 120000); // check for new messages every 2 mins. - this.setState({ // eslint-disable-line react/no-did-update-set-state - messageCountIntervalId: intervalId - }); + const twoMinInMs = 2 * 60 * 1000; + this.messageCountTimeoutId = setTimeout(this.pollForMessages.bind(this, twoMinInMs), twoMinInMs); } else { - // clear message count check, and set to default id. - clearInterval(this.state.messageCountIntervalId); + // Clear message count check, and set to default id. + if (this.messageCountTimeoutId !== -1) { + clearTimeout(this.messageCountTimeoutId); + } this.props.setMessageCount(0); - this.setState({ // eslint-disable-line react/no-did-update-set-state - messageCountIntervalId: -1 - }); + this.messageCountTimeoutId = -1; } } } componentWillUnmount () { // clear message interval if it exists - if (this.state.messageCountIntervalId !== -1) { - clearInterval(this.state.messageCountIntervalId); + if (this.messageCountTimeoutId !== -1) { + clearTimeout(this.messageCountTimeoutId); this.props.setMessageCount(0); - this.setState({ - messageCountIntervalId: -1 - }); + this.messageCountTimeoutId = -1; } } getProfileUrl () { if (!this.props.user) return; return `/users/${this.props.user.username}/`; } + + pollForMessages (ms) { + this.props.getMessageCount(this.props.user.username); + // We only poll if it has been less than 32 minutes. + // Chances of someone actively using the page for that long without + // a navigation is low. + if (ms < 32 * 60 * 1000) { // 32 minutes + const nextFetch = ms * 2; // exponentially back off next fetch time. + const timeoutId = setTimeout(() => { + this.pollForMessages(nextFetch); + }, nextFetch); + this.messageCountTimeoutId = timeoutId; + } + } + handleSearchSubmit (formData) { let targetUrl = '/search/projects'; if (formData.q) { diff --git a/test/unit/components/navigation.test.jsx b/test/unit/components/navigation.test.jsx index 9a9547a9a..924e83551 100644 --- a/test/unit/components/navigation.test.jsx +++ b/test/unit/components/navigation.test.jsx @@ -1,5 +1,5 @@ const React = require('react'); -const {shallowWithIntl} = require('../../helpers/intl-helpers.jsx'); +const {shallowWithIntl, mountWithIntl} = require('../../helpers/intl-helpers.jsx'); import configureStore from 'redux-mock-store'; const Navigation = require('../../../src/components/navigation/www/navigation.jsx'); const Registration = require('../../../src/components/registration/registration.jsx'); @@ -11,6 +11,7 @@ describe('Navigation', () => { beforeEach(() => { store = null; + jest.useFakeTimers(); }); const getNavigationWrapper = props => { @@ -103,4 +104,123 @@ describe('Navigation', () => { navWrapper.find('a.registrationLink').simulate('click', {preventDefault () {}}); expect(navInstance.props.handleClickRegistration).toHaveBeenCalled(); }); + + test('Component sets up message polling when it mounts', () => { + store = mockStore({ + navigation: { + registrationOpen: false + }, + messageCount: { + messageCount: 5 + } + }); + const props = { + user: { + thumbnailUrl: 'scratch.mit.edu', + username: 'auser' + }, + getMessageCount: jest.fn() + }; + const intlWrapper = mountWithIntl( + , {context: {store}, + childContextTypes: {store} + }); + + const navInstance = intlWrapper.children().find('Navigation') + .instance(); + const twoMin = 2 * 60 * 1000; + expect(setTimeout).toHaveBeenLastCalledWith(expect.any(Function), twoMin); + expect(navInstance.messageCountTimeoutId).not.toEqual(-1); + // Advance timers passed the intial two minutes. + jest.advanceTimersByTime(twoMin + 1); + expect(setTimeout).toHaveBeenLastCalledWith(expect.any(Function), twoMin * 2); + expect(props.getMessageCount).toHaveBeenCalled(); + expect(navInstance.messageCountTimeoutId).not.toEqual(-1); + }); + test('Component cancels timers when it unmounts', () => { + store = mockStore({ + navigation: { + registrationOpen: false + }, + messageCount: { + messageCount: 5 + } + }); + const props = { + user: { + thumbnailUrl: 'scratch.mit.edu', + username: 'auser' + }, + getMessageCount: jest.fn() + }; + const intlWrapper = mountWithIntl( + , {context: {store}, + childContextTypes: {store} + }); + + const navInstance = intlWrapper.children().find('Navigation') + .instance(); + const twoMin = 2 * 60 * 1000; + expect(setTimeout).toHaveBeenLastCalledWith(expect.any(Function), twoMin); + expect(navInstance.messageCountTimeoutId).not.toEqual(-1); + navInstance.componentWillUnmount(); + expect(clearTimeout).toHaveBeenCalledWith(expect.any(Number)); + expect(navInstance.messageCountTimeoutId).toEqual(-1); + }); + + test('pollForMessages polls for messages 5 times', () => { + store = mockStore({ + navigation: { + registrationOpen: false + }, + messageCount: { + messageCount: 5 + } + }); + const props = { + user: { + thumbnailUrl: 'scratch.mit.edu', + username: 'auser' + }, + getMessageCount: jest.fn() + }; + const intlWrapper = mountWithIntl( + , {context: {store}, + childContextTypes: {store} + }); + + const navInstance = intlWrapper.children().find('Navigation') + .instance(); + // Clear the timers and mocks because componentDidMount + // has already called pollForMessages. + jest.clearAllTimers(); + jest.clearAllMocks(); + let twoMinInMs = 2 * 60 * 1000; // 2 minutes in ms. + navInstance.pollForMessages(twoMinInMs); + + expect(navInstance.messageCountTimeoutId).not.toEqual(-1); + // Check that we set the timeout to backoff exponentially + for (let count = 1; count < 5; ++count) { + jest.advanceTimersByTime(twoMinInMs + 1); + expect(setTimeout).toHaveBeenLastCalledWith(expect.any(Function), twoMinInMs * 2); + expect(props.getMessageCount).toHaveBeenCalledTimes(count); + twoMinInMs = twoMinInMs * 2; + } + + // Exhaust all timers (there shouldn't be any left) + jest.runAllTimers(); + // We exponentially back off checking for messages, starting at 2 min + // and stop after 32 minutes so it should happen 5 times total. + expect(props.getMessageCount).toHaveBeenCalledTimes(5); + // setTimeout happens 1 fewer since the original call to + // pollForMessages isn't counted here. + expect(setTimeout).toHaveBeenCalledTimes(4); + expect(navInstance.messageCountTimeoutId).not.toEqual(-1); + }); });