diff --git a/src/default.js b/src/default.js index 41ecf1c..a83f65a 100644 --- a/src/default.js +++ b/src/default.js @@ -58,7 +58,6 @@ var offsetHeight = "offset" + Height; var clientHeight = "client" + Height; var clientWidth = "client" + Width; - var pagehide = "pagehide"; var platformText = "platform"; var platformVersionText = "platformVersion"; var docsUrl = "https://docs.simpleanalytics.com"; @@ -432,10 +431,6 @@ // INITIALIZE VALUES // - /** if duration **/ - var start = now(); - /** endif **/ - /** if scroll **/ var scrolled = 0; /** endif **/ @@ -646,11 +641,46 @@ // TIME ON PAGE AND SCROLLED LOGIC // - // We don't put msHidden in if duration block, because it's used outside of that functionality - var msHidden = 0; + /** if (or duration scroll) **/ + /** if duration **/ + // Only visible time counts as time on page + var isVisible = function () { + return !doc.hidden; + }; + var visibleMs = 0; + var visibleSince = isVisible() ? now() : 0; + + var updateVisibleTime = function () { + var time = now(); + if (visibleSince) visibleMs += time - visibleSince; + visibleSince = isVisible() ? time : 0; + }; + + // Optionally send the visible time when it passes thresholds (seconds), + // e.g. data-pings="5,60", so we keep a lower bound when no leave event fires + var pings = convertCommaSeparatedToArray( + settings.pings || attr(scriptElement, "pings") + ).sort(function (a, b) { + return a - b; + }); + var pingTimer; + var schedulePing = function () { + clearTimeout(pingTimer); + updateVisibleTime(); + for (var index = 0; pages && isVisible() && index < pings.length; index++) { + var ms = pings[index] * thousand - visibleMs; + // Skip invalid values and values too large for setTimeout + if (ms > 0 && ms < 2e9) + return (pingTimer = setTimeout(function () { + sendOnLeave(); + schedulePing(); + }, ms)); + } + }; + /** endif **/ var sendOnLeave = function (id, push) { - if (!collectDataOnLeave) return; + if (!collectDataOnLeave || !pages) return; var append = assign(basePayload, { type: "append", @@ -660,10 +690,9 @@ /** if duration **/ // t = timeonpage if (collectMetricByString("t")) { - append.duration = Math.round((now() - start - msHidden) / thousand); + updateVisibleTime(); + append.duration = Math.floor(visibleMs / thousand); } - msHidden = 0; - start = now(); /** endif **/ /** if scroll **/ @@ -673,34 +702,29 @@ } /** endif **/ - if (push || !nav.sendBeacon) { - // sendData will assign payload to request - sendData(append, undefinedVar, trueVar); - } else { - try { - nav.sendBeacon.bind(nav)(fullApiUrl + "/append", stringify(append)); - } catch (e) { - // Fallback for browsers throwing "Illegal invocation" when the URL is invalid - sendData(append, undefinedVar, trueVar); - } - } + // The queue drops appends without values + if (!append.duration && !append.scrolled) return; + + // sendData will assign payload to request + if (push || !nav.sendBeacon) sendData(append, undefinedVar, trueVar); + else nav.sendBeacon(fullApiUrl + "/append", stringify(append)); }; /** if duration **/ - var hiddenStart; - addEventListenerFunc( + // Getting hidden is the last event that fires reliably, also when mobile + // browsers are killed later, so send the values every time it happens. + // Leaving a page fires it too, so we don't listen to pagehide. + doc.addEventListener( "visibilitychange", function () { - if (doc.hidden) { - if (!("on" + pagehide in window)) sendOnLeave(); - hiddenStart = now(); - } else msHidden += now() - hiddenStart; + updateVisibleTime(); + if (!isVisible()) sendOnLeave(); + schedulePing(); }, falseVar ); /** endif **/ - - addEventListenerFunc(pagehide, sendOnLeave, falseVar); + /** endif **/ /** if scroll **/ var body = doc.body || {}; @@ -793,7 +817,19 @@ metadata, callback ) { - if (isPushState) sendOnLeave("" + payload.page_id, trueVar); + /** if (or duration scroll) **/ + // Finish the previous page view (SPA navigation or manual pageview) + if (pages) sendOnLeave("" + payload.page_id, trueVar); + + // Every page view starts with its own counters + /** if duration **/ + updateVisibleTime(); + visibleMs = 0; + /** endif **/ + /** if scroll **/ + scrolled = 0; + /** endif **/ + /** endif **/ if (collectDataOnLeave) payload.page_id = uuid(); var currentPage = definedHostname + getPath(); @@ -820,6 +856,10 @@ referrer = currentPage; pages++; + + /** if duration **/ + schedulePing(); + /** endif **/ }; var sameSite, userNavigated; diff --git a/test/unit/pageview.test.js b/test/unit/pageview.test.js index 19efe50..9de113f 100644 --- a/test/unit/pageview.test.js +++ b/test/unit/pageview.test.js @@ -114,19 +114,50 @@ describe("pageview", function () { dom.window.close(); }); + // Lets tests move the clock the script uses for time on page + const withClock = (options = {}) => { + const clock = { time: 1e12 }; + const dom = createDOM({ + ...options, + beforeRun(context) { + context.Date.now = () => clock.time; + }, + }); + dom.clock = clock; + return dom; + }; + + const setVisibility = (dom, state) => { + Object.defineProperty(dom.window.document, "hidden", { + configurable: true, + value: state == "hidden", + }); + dom.window.document.dispatchEvent(new dom.window.Event("visibilitychange")); + }; + + const appends = (dom) => + dom.sent + .filter((r) => r.type === "beacon") + .map((r) => JSON.parse(r.data).duration); + + it("sends visible time when hidden and the total after returning", function () { + const dom = withClock(); + dom.clock.time += 3000; + setVisibility(dom, "hidden"); + dom.clock.time += 60000; + setVisibility(dom, "visible"); + dom.clock.time += 2000; + setVisibility(dom, "hidden"); + expect(appends(dom)).to.deep.equal([3, 5]); + dom.window.close(); + }); + it("sends pageview, event and beacon requests", function (done) { - const dom = createDOM({ navigationType: "reload" }); + const dom = withClock({ navigationType: "reload" }); + dom.clock.time += 2000; dom.window.sa_event("unit_test"); - - if (!("onpagehide" in dom.window)) { - dom.window.document.hidden = true; - dom.window.document.dispatchEvent( - new dom.window.Event("visibilitychange") - ); - } else { - dom.window.dispatchEvent(new dom.window.Event("pagehide")); - } + setVisibility(dom, "hidden"); setTimeout(() => { const gif = dom.sent.find( @@ -146,34 +177,4 @@ describe("pageview", function () { done(); }, 10); }); - - it("falls back to pixel when sendBeacon fails", function (done) { - const dom = createDOM({ navigationType: "reload" }); - - dom.window.navigator.sendBeacon = function () { - throw new TypeError("Illegal invocation"); - }; - - dom.window.sa_event("unit_test"); - - if (!("onpagehide" in dom.window)) { - dom.window.document.hidden = true; - dom.window.document.dispatchEvent( - new dom.window.Event("visibilitychange") - ); - } else { - dom.window.dispatchEvent(new dom.window.Event("pagehide")); - } - - setTimeout(() => { - const beacon = dom.sent.find((r) => r.type === "beacon"); - const appendGif = dom.sent.find( - (r) => r.type === "image" && /type=append/.test(r.url) - ); - - expect(beacon, "append beacon request").to.not.exist; - expect(appendGif, "append gif request").to.exist; - done(); - }, 10); - }); });