From 0db5854eb6dd7bd1e14f8fcaf593a0d3fa288320 Mon Sep 17 00:00:00 2001 From: Conductor Adriaan Date: Mon, 19 Oct 2026 17:27:32 +0000 Subject: [PATCH 1/4] Send visible time on page when the page gets hidden Count only visible time and send the cumulative value on every hide and on pagehide, so durations survive mobile app switches, screen locks and browser kills, back/forward cache restores no longer send increments, and Firefox tab closes are no longer lost. Every page view, including manual ones, closes the previous one. Optional data-pings sends the same value at visible-time thresholds. Co-Authored-By: Claude Opus 5.5 (1M context) --- src/default.js | 144 +++++++++++++++++++++++++++++-------- test/unit/pageview.test.js | 45 +++++++++++- 2 files changed, 159 insertions(+), 30 deletions(-) diff --git a/src/default.js b/src/default.js index 41ecf1c..69db1f8 100644 --- a/src/default.js +++ b/src/default.js @@ -432,10 +432,6 @@ // INITIALIZE VALUES // - /** if duration **/ - var start = now(); - /** endif **/ - /** if scroll **/ var scrolled = 0; /** endif **/ @@ -646,11 +642,51 @@ // 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) **/ + // Values of the last queued append request of this page view. Both values + // only grow, so we skip requests that would not change anything. + var sentDuration = 0; + var sentScrolled = 0; + + /** if duration **/ + // Only visible time counts as time on page + var isVisible = function () { + return doc.visibilityState != "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 that overflow 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,47 +696,82 @@ /** 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 **/ // scro = scrolled if (collectMetricByString("scro")) { - append.scrolled = Math.max(0, scrolled, position()); + append.scrolled = scrolled = Math.max(0, scrolled, position()); } /** endif **/ - if (push || !nav.sendBeacon) { - // sendData will assign payload to request - sendData(append, undefinedVar, trueVar); - } else { - try { + if (!(append.duration > sentDuration || append.scrolled > sentScrolled)) + return; + + var queued; + try { + queued = + !push && + nav.sendBeacon && 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); - } + } catch (e) { + // Fallback for browsers throwing "Illegal invocation" when the URL is invalid + } + + // sendData will assign payload to request + if (!queued) sendData(append, undefinedVar, trueVar); + + // Only skip identical values later when the beacon was queued (or for + // the image of a page view that just ended), so a later event can retry + if (queued || push) { + sentDuration = append.duration; + sentScrolled = append.scrolled; } }; /** if duration **/ - var hiddenStart; - addEventListenerFunc( + // The page can be closed or killed while hidden without any other event + // firing (common on mobile), so send the values every time it gets hidden. + // Listen on document, Safari < 14 doesn't bubble this event to window. + 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 + ); + + // Count nothing while the page is in the back/forward cache, even when a + // browser doesn't fire visibilitychange around it + addEventListenerFunc( + "pageshow", + function (event) { + if (event.persisted) { + visibleSince = isVisible() ? now() : 0; + schedulePing(); + } }, falseVar ); /** endif **/ - addEventListenerFunc(pagehide, sendOnLeave, falseVar); + addEventListenerFunc( + pagehide, + function () { + sendOnLeave(); + /** if duration **/ + visibleSince = 0; + clearTimeout(pingTimer); + /** endif **/ + }, + falseVar + ); + /** endif **/ /** if scroll **/ var body = doc.body || {}; @@ -793,7 +864,20 @@ 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 + sentDuration = sentScrolled = 0; + /** if duration **/ + updateVisibleTime(); + visibleMs = 0; + /** endif **/ + /** if scroll **/ + scrolled = 0; + /** endif **/ + /** endif **/ if (collectDataOnLeave) payload.page_id = uuid(); var currentPage = definedHostname + getPath(); @@ -820,6 +904,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..3ed6504 100644 --- a/test/unit/pageview.test.js +++ b/test/unit/pageview.test.js @@ -114,8 +114,48 @@ 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, "visibilityState", { + configurable: true, + value: state, + }); + 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; + dom.window.dispatchEvent(new dom.window.Event("pagehide")); + 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"); @@ -148,7 +188,8 @@ describe("pageview", function () { }); it("falls back to pixel when sendBeacon fails", function (done) { - const dom = createDOM({ navigationType: "reload" }); + const dom = withClock({ navigationType: "reload" }); + dom.clock.time += 2000; dom.window.navigator.sendBeacon = function () { throw new TypeError("Illegal invocation"); From 1cd12c6de4a73f80f7cbf642fab457a400c135e9 Mon Sep 17 00:00:00 2001 From: Conductor Adriaan Date: Mon, 19 Oct 2026 17:42:28 +0000 Subject: [PATCH 2/4] Keep invalid scroll readings out of later appends Don't write position() back into the scroll maximum, so a NaN reading on a zero-height layout can't stick for the rest of the page view, and drop the unobservable push branch of the sent watermark. Co-Authored-By: Claude Opus 5.5 (1M context) --- src/default.js | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/src/default.js b/src/default.js index 69db1f8..efa37d1 100644 --- a/src/default.js +++ b/src/default.js @@ -704,7 +704,7 @@ /** if scroll **/ // scro = scrolled if (collectMetricByString("scro")) { - append.scrolled = scrolled = Math.max(0, scrolled, position()); + append.scrolled = Math.max(0, scrolled, position()); } /** endif **/ @@ -724,9 +724,9 @@ // sendData will assign payload to request if (!queued) sendData(append, undefinedVar, trueVar); - // Only skip identical values later when the beacon was queued (or for - // the image of a page view that just ended), so a later event can retry - if (queued || push) { + // Only skip identical values later when the beacon was queued, so a + // later event can retry after the image fallback + if (queued) { sentDuration = append.duration; sentScrolled = append.scrolled; } From 74ad26039ca562033becce0e966ee0063797d39f Mon Sep 17 00:00:00 2001 From: Conductor Adriaan Date: Mon, 19 Oct 2026 22:16:41 +0000 Subject: [PATCH 3/4] Send time on page only when the page gets hidden Leaving a page also makes it hidden, so the pagehide listener, the duplicate-send guard, the back/forward cache fallback and the beacon try/catch are no longer needed. Old browsers that don't fire visibilitychange on navigation lose those durations; this keeps the script small. Appends without values are not sent. Co-Authored-By: Claude Opus 5.5 (1M context) --- src/default.js | 69 ++++++-------------------------------- test/unit/pageview.test.js | 46 ++----------------------- 2 files changed, 13 insertions(+), 102 deletions(-) diff --git a/src/default.js b/src/default.js index efa37d1..f58cbc3 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"; @@ -643,15 +642,10 @@ // /** if (or duration scroll) **/ - // Values of the last queued append request of this page view. Both values - // only grow, so we skip requests that would not change anything. - var sentDuration = 0; - var sentScrolled = 0; - /** if duration **/ // Only visible time counts as time on page var isVisible = function () { - return doc.visibilityState != "hidden"; + return !doc.hidden; }; var visibleMs = 0; var visibleSince = isVisible() ? now() : 0; @@ -675,8 +669,7 @@ updateVisibleTime(); for (var index = 0; pages && isVisible() && index < pings.length; index++) { var ms = pings[index] * thousand - visibleMs; - // Skip invalid values and values that overflow setTimeout - if (ms > 0 && ms < 2e9) + if (ms > 0) return (pingTimer = setTimeout(function () { sendOnLeave(); schedulePing(); @@ -686,7 +679,7 @@ /** endif **/ var sendOnLeave = function (id, push) { - if (!collectDataOnLeave || !pages) return; + if (!collectDataOnLeave) return; var append = assign(basePayload, { type: "append", @@ -708,34 +701,18 @@ } /** endif **/ - if (!(append.duration > sentDuration || append.scrolled > sentScrolled)) - return; - - var queued; - try { - queued = - !push && - nav.sendBeacon && - nav.sendBeacon.bind(nav)(fullApiUrl + "/append", stringify(append)); - } catch (e) { - // Fallback for browsers throwing "Illegal invocation" when the URL is invalid - } + // The queue drops appends without values + if (!append.duration && !append.scrolled) return; // sendData will assign payload to request - if (!queued) sendData(append, undefinedVar, trueVar); - - // Only skip identical values later when the beacon was queued, so a - // later event can retry after the image fallback - if (queued) { - sentDuration = append.duration; - sentScrolled = append.scrolled; - } + if (push || !nav.sendBeacon) sendData(append, undefinedVar, trueVar); + else nav.sendBeacon(fullApiUrl + "/append", stringify(append)); }; /** if duration **/ - // The page can be closed or killed while hidden without any other event - // firing (common on mobile), so send the values every time it gets hidden. - // Listen on document, Safari < 14 doesn't bubble this event to window. + // 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 () { @@ -745,32 +722,7 @@ }, falseVar ); - - // Count nothing while the page is in the back/forward cache, even when a - // browser doesn't fire visibilitychange around it - addEventListenerFunc( - "pageshow", - function (event) { - if (event.persisted) { - visibleSince = isVisible() ? now() : 0; - schedulePing(); - } - }, - falseVar - ); /** endif **/ - - addEventListenerFunc( - pagehide, - function () { - sendOnLeave(); - /** if duration **/ - visibleSince = 0; - clearTimeout(pingTimer); - /** endif **/ - }, - falseVar - ); /** endif **/ /** if scroll **/ @@ -869,7 +821,6 @@ if (pages) sendOnLeave("" + payload.page_id, trueVar); // Every page view starts with its own counters - sentDuration = sentScrolled = 0; /** if duration **/ updateVisibleTime(); visibleMs = 0; diff --git a/test/unit/pageview.test.js b/test/unit/pageview.test.js index 3ed6504..9de113f 100644 --- a/test/unit/pageview.test.js +++ b/test/unit/pageview.test.js @@ -128,9 +128,9 @@ describe("pageview", function () { }; const setVisibility = (dom, state) => { - Object.defineProperty(dom.window.document, "visibilityState", { + Object.defineProperty(dom.window.document, "hidden", { configurable: true, - value: state, + value: state == "hidden", }); dom.window.document.dispatchEvent(new dom.window.Event("visibilitychange")); }; @@ -147,7 +147,6 @@ describe("pageview", function () { dom.clock.time += 60000; setVisibility(dom, "visible"); dom.clock.time += 2000; - dom.window.dispatchEvent(new dom.window.Event("pagehide")); setVisibility(dom, "hidden"); expect(appends(dom)).to.deep.equal([3, 5]); dom.window.close(); @@ -158,15 +157,7 @@ describe("pageview", function () { 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( @@ -186,35 +177,4 @@ describe("pageview", function () { done(); }, 10); }); - - it("falls back to pixel when sendBeacon fails", function (done) { - const dom = withClock({ navigationType: "reload" }); - dom.clock.time += 2000; - - 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); - }); }); From 36bec96f7e17ceaf2d8141393882796122b9dda0 Mon Sep 17 00:00:00 2001 From: Conductor Adriaan Date: Mon, 19 Oct 2026 22:27:07 +0000 Subject: [PATCH 4/4] Ignore huge ping values and hides before the first page view A ping value too large for setTimeout fired immediately and rescheduled itself in a loop, and ignored pages or manual mode sent an append for a page view that was never sent on every hide. Co-Authored-By: Claude Opus 5.5 (1M context) --- src/default.js | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/src/default.js b/src/default.js index f58cbc3..a83f65a 100644 --- a/src/default.js +++ b/src/default.js @@ -669,7 +669,8 @@ updateVisibleTime(); for (var index = 0; pages && isVisible() && index < pings.length; index++) { var ms = pings[index] * thousand - visibleMs; - if (ms > 0) + // Skip invalid values and values too large for setTimeout + if (ms > 0 && ms < 2e9) return (pingTimer = setTimeout(function () { sendOnLeave(); schedulePing(); @@ -679,7 +680,7 @@ /** endif **/ var sendOnLeave = function (id, push) { - if (!collectDataOnLeave) return; + if (!collectDataOnLeave || !pages) return; var append = assign(basePayload, { type: "append",