diff --git a/src/utils/focusTimeout.js b/src/utils/focusTimeout.js new file mode 100644 index 000000000000..0ba66cc4efc2 --- /dev/null +++ b/src/utils/focusTimeout.js @@ -0,0 +1,41 @@ +let outOfFocusStart; +let timeOutOfFocus = 0; +let suspendedTimeouts = []; + +document.addEventListener('visibilitychange', () => { + if (document.hidden) { + outOfFocusStart = Date.now() + } else { + timeOutOfFocus += Date.now() - outOfFocusStart + suspendedTimeouts.forEach(({ callback, startTime, setTimerId }) => setTimerId(setFocusTimeout(callback, timeOutOfFocus - startTime)())) + outOfFocusStart = null; + } +}); + +/** + * Wraps native setTimeout function in order to count time only when page is focused + * + * @param {function(*): ()} [callback] - A function that will be invoked after passed time + * @param {number} [milliseconds] - Minimum duration (in milliseconds) that the callback will be executed after + * @returns {function(*): (number)} - Getter function for current timer id + */ +export default function setFocusTimeout(callback, milliseconds) { + const startTime = timeOutOfFocus; + let timerId = setTimeout(() => { + if (timeOutOfFocus === startTime && outOfFocusStart == null) { + callback(); + } else if (outOfFocusStart != null) { + // case when timeout ended during page is out of focus + suspendedTimeouts.push({ + callback, + startTime, + setTimerId(newId) { + timerId = newId; + } + }) + } else { + timerId = setFocusTimeout(callback, timeOutOfFocus - startTime)(); + } + }, milliseconds); + return () => timerId; +} diff --git a/src/utils/ttlCollection.js b/src/utils/ttlCollection.js index 2294c0721088..b6e0a5198df2 100644 --- a/src/utils/ttlCollection.js +++ b/src/utils/ttlCollection.js @@ -1,5 +1,6 @@ import {GreedyPromise} from './promise.js'; import {binarySearch, logError, timestamp} from '../utils.js'; +import setFocusTimeout from './focusTimeout.js'; /** * Create a set-like collection that automatically forgets items after a certain time. @@ -46,7 +47,7 @@ export function ttlCollection( if (pendingPurge.length > 0) { const now = timestamp(); nextPurge = Math.max(now, pendingPurge[0].expiry + slack); - task = setTimeout(() => { + task = setFocusTimeout(() => { const now = timestamp(); let cnt = 0; for (const entry of pendingPurge) { diff --git a/test/spec/auctionmanager_spec.js b/test/spec/auctionmanager_spec.js index 26f641a10e7d..ab00ac86d985 100644 --- a/test/spec/auctionmanager_spec.js +++ b/test/spec/auctionmanager_spec.js @@ -27,6 +27,7 @@ import {PrebidServer} from '../../modules/prebidServerBidAdapter/index.js'; import '../../modules/currency.js' import { setConfig as setCurrencyConfig } from '../../modules/currency.js'; import { REJECTION_REASON } from '../../src/constants.js'; +import { setDocumentHidden } from './unit/utils/focusTimeout_spec.js'; /** * @typedef {import('../src/adapters/bidderFactory.js').BidRequest} BidRequest @@ -888,6 +889,20 @@ describe('auctionmanager.js', function () { }) }); + it('are not dropped after `minBidCacheTTL` seconds if the page was hidden', () => { + auction.callBids(); + config.setConfig({ + minBidCacheTTL: 10 + }); + return auction.end.then(() => { + expect(auctionManager.getNoBids().length).to.eql(1); + setDocumentHidden(true); + clock.tick(10 * 10000); + setDocumentHidden(false); + expect(auctionManager.getNoBids().length).to.eql(1); + }) + }); + Object.entries({ 'bids': { bd: [{ diff --git a/test/spec/unit/utils/focusTimeout_spec.js b/test/spec/unit/utils/focusTimeout_spec.js new file mode 100644 index 000000000000..ed7b1c0c2f30 --- /dev/null +++ b/test/spec/unit/utils/focusTimeout_spec.js @@ -0,0 +1,60 @@ +import setFocusTimeout from '../../../../src/utils/focusTimeout'; + +export const setDocumentHidden = (hidden) => { + Object.defineProperty(document, 'hidden', { + configurable: true, + get: () => hidden, + }); + document.dispatchEvent(new Event('visibilitychange')); +}; + +describe('focusTimeout', () => { + let clock; + + beforeEach(() => { + clock = sinon.useFakeTimers(); + }); + + afterEach(() => { + clock.restore(); + }) + + it('should invoke callback when page is visible', () => { + let callback = sinon.stub(); + setFocusTimeout(callback, 2000); + clock.tick(2000); + expect(callback.called).to.be.true; + }); + + it('should not invoke callback if page was hidden', () => { + let callback = sinon.stub(); + setFocusTimeout(callback, 2000); + setDocumentHidden(true); + clock.tick(3000); + expect(callback.called).to.be.false; + }); + + it('should defer callback execution when page is hidden', () => { + let callback = sinon.stub(); + setFocusTimeout(callback, 4000); + clock.tick(2000); + setDocumentHidden(true); + clock.tick(2000); + setDocumentHidden(false); + expect(callback.called).to.be.false; + clock.tick(2000); + expect(callback.called).to.be.true; + }); + + it('should return updated timerId after page was showed again', () => { + let callback = sinon.stub(); + const getCurrentTimerId = setFocusTimeout(callback, 4000); + const oldTimerId = getCurrentTimerId(); + clock.tick(2000); + setDocumentHidden(true); + clock.tick(2000); + setDocumentHidden(false); + const newTimerId = getCurrentTimerId(); + expect(oldTimerId).to.not.equal(newTimerId); + }); +});