From 744671c071be3018bc93bf6381d77f807c6dea75 Mon Sep 17 00:00:00 2001 From: Henric Persson Date: Fri, 9 Oct 2026 07:21:01 -0500 Subject: [PATCH] [Tracker] Read scroll metrics when the browser is idle The ResizeObserver callback read the document height, and the scroll listener read window.scrollY. Each read makes the browser finish any pending layout before it answers, in the middle of a frame. On a page with a large DOM that blocks the main thread for hundreds of milliseconds. Both now schedule one update for when the browser is idle. An engagement makes a pending update first, so the scroll depth it sends is up to date. Fixes #6764 Co-Authored-By: Claude Opus 5.5 --- CHANGELOG.md | 1 + tracker/npm_package/CHANGELOG.md | 2 ++ tracker/package.json | 2 +- tracker/src/engagement.js | 49 ++++++++++++++++++++++++------- tracker/test/scroll-depth.spec.js | 41 ++++++++++++++++++++++++++ 5 files changed, 84 insertions(+), 11 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 152e219cac06..44d8ae7af183 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -48,6 +48,7 @@ All notable changes to this project will be documented in this file. ### Fixed +- Tracker script no longer forces a layout on every scroll event and document resize to measure scroll depth, which blocked the main thread on pages with a large DOM - Stats API `/api/v1/stats/breakdown` now returns a `400` error for an invalid `page` parameter instead of crashing with a `500` - Fixed "Create Team" going through when the team name was rejected, creating the team under a name the user never entered - Improve team member removal/team role change diff --git a/tracker/npm_package/CHANGELOG.md b/tracker/npm_package/CHANGELOG.md index 651df4ae6033..95c157551839 100644 --- a/tracker/npm_package/CHANGELOG.md +++ b/tracker/npm_package/CHANGELOG.md @@ -7,6 +7,8 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## Unreleased +- Read scroll metrics when the browser is idle instead of inside the resize observer and the scroll listener, where they forced a layout + ## [0.4.6] - 2026-08-10 - Fix issue with package resolution by adding "exports" field diff --git a/tracker/package.json b/tracker/package.json index d867d021601f..973870065753 100644 --- a/tracker/package.json +++ b/tracker/package.json @@ -1,5 +1,5 @@ { - "tracker_script_version": 36, + "tracker_script_version": 37, "type": "module", "scripts": { "lint": "eslint", diff --git a/tracker/src/engagement.js b/tracker/src/engagement.js index 1dba2e5c4f62..5576ecd018c1 100644 --- a/tracker/src/engagement.js +++ b/tracker/src/engagement.js @@ -26,6 +26,7 @@ export function prePageviewTrack() { // has been sent by the current script (i.e. it's most likely a SPA). // Trigger an engagement marking the "exit from the previous page". triggerEngagement() + scrollMetricsUpdateScheduled = false currentDocumentHeight = getDocumentHeight() maxScrollDepthPx = getCurrentScrollDepthPx() } @@ -48,6 +49,12 @@ export function onPageviewIgnored() { function triggerEngagement() { var engagementTime = getEngagementTime() + // An update may still be waiting for the browser to be idle. The engagement + // has to carry the latest scroll metrics, so make the update now. + if (scrollMetricsUpdateScheduled) { + updateScrollMetrics() + } + /* We send engagements if there's new relevant engagement information to share: - If the user has scrolled more than the previously sent max scroll depth. @@ -122,6 +129,7 @@ function getEngagementTime() { var currentDocumentHeight var maxScrollDepthPx +var scrollMetricsUpdateScheduled = false function getDocumentHeight() { var body = document.body || {} @@ -157,6 +165,33 @@ function getCurrentScrollDepthPx() { : scrollTop + viewportHeight } +function updateScrollMetrics() { + scrollMetricsUpdateScheduled = false + currentDocumentHeight = getDocumentHeight() + var currentScrollDepthPx = getCurrentScrollDepthPx() + + if (currentScrollDepthPx > maxScrollDepthPx) { + maxScrollDepthPx = currentScrollDepthPx + } +} + +// Reading the document height or the scroll position makes the browser finish +// any pending layout before it answers. Inside a ResizeObserver callback or a +// scroll listener that happens in the middle of a frame, and on a page with a +// large DOM it can block the main thread for hundreds of milliseconds. So the +// reads wait until the browser is idle, when layout is already done, and any +// number of resizes and scrolls before that lead to a single update. +function scheduleScrollMetricsUpdate() { + if (!scrollMetricsUpdateScheduled) { + scrollMetricsUpdateScheduled = true + if (window.requestIdleCallback) { + window.requestIdleCallback(updateScrollMetrics) + } else { + setTimeout(updateScrollMetrics, 0) + } + } +} + export function init() { currentDocumentHeight = getDocumentHeight() maxScrollDepthPx = getCurrentScrollDepthPx() @@ -171,16 +206,10 @@ export function init() { }, 200) }) } else { - new ResizeObserver(function () { - currentDocumentHeight = getDocumentHeight() - }).observe(document.documentElement) + new ResizeObserver(scheduleScrollMetricsUpdate).observe( + document.documentElement + ) } - document.addEventListener('scroll', function () { - var currentScrollDepthPx = getCurrentScrollDepthPx() - - if (currentScrollDepthPx > maxScrollDepthPx) { - maxScrollDepthPx = currentScrollDepthPx - } - }) + document.addEventListener('scroll', scheduleScrollMetricsUpdate) } diff --git a/tracker/test/scroll-depth.spec.js b/tracker/test/scroll-depth.spec.js index daf9ed58c2bf..5cf8261d5edb 100644 --- a/tracker/test/scroll-depth.spec.js +++ b/tracker/test/scroll-depth.spec.js @@ -137,6 +137,47 @@ test.describe('scroll depth (engagement events)', () => { }) }) + test('does not read the scroll position inside the scroll listener', async ({ + page + }) => { + // Reading window.scrollY forces the browser to finish pending layout, so + // the tracker must not do it while a scroll event is being dispatched. + await page.addInitScript(() => { + const { get } = Object.getOwnPropertyDescriptor(window, 'scrollY') + window.scrollYReads = 0 + Object.defineProperty(window, 'scrollY', { + configurable: true, + get() { + window.scrollYReads++ + return get.call(window) + } + }) + }) + + await expectPlausibleInAction(page, { + action: () => page.goto('/scroll-depth.html'), + expectedRequests: [{ n: 'pageview' }] + }) + + const readsWhileScrolling = await page.evaluate(() => { + window.scrollTo(0, 300) + const readsBefore = window.scrollYReads + for (let i = 0; i < 10; i++) { + document.dispatchEvent(new Event('scroll')) + } + return window.scrollYReads - readsBefore + }) + expect(readsWhileScrolling).toBe(0) + + // The scroll depth is still up to date when the engagement is sent. + await expectPlausibleInAction(page, { + action: () => page.click('#navigate-away'), + expectedRequests: [ + { n: 'engagement', u: `${LOCAL_SERVER_ADDR}/scroll-depth.html`, sd: 20 } + ] + }) + }) + test('sends scroll depth when minimizing the tab', async ({ page }) => { await expectPlausibleInAction(page, { action: () => page.goto('/scroll-depth.html'),