Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
2 changes: 2 additions & 0 deletions tracker/npm_package/CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
2 changes: 1 addition & 1 deletion tracker/package.json
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
{
"tracker_script_version": 36,
"tracker_script_version": 37,
"type": "module",
"scripts": {
"lint": "eslint",
Expand Down
49 changes: 39 additions & 10 deletions tracker/src/engagement.js
Original file line number Diff line number Diff line change
Expand Up @@ -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()
}
Expand All @@ -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.
Expand Down Expand Up @@ -122,6 +129,7 @@ function getEngagementTime() {

var currentDocumentHeight
var maxScrollDepthPx
var scrollMetricsUpdateScheduled = false

function getDocumentHeight() {
var body = document.body || {}
Expand Down Expand Up @@ -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()
Expand All @@ -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)
}
41 changes: 41 additions & 0 deletions tracker/test/scroll-depth.spec.js
Original file line number Diff line number Diff line change
Expand Up @@ -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'),
Expand Down