Scroll depth: Register `pageleave` listener earlier (#5000)

* Register `pageleave` listener earlier

Currently, pageleave listener is only registered when a successful
response is received from plausible API.

After this change pageleave listener is registered immediately when
pageview is triggered, hopefully increasing capture rate.

* Codespell
This commit is contained in:
Karl-Aksel Puulmann 2025-01-22 11:30:23 +02:00 committed by GitHub
parent 714f7f4603
commit 8d238cd340
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
8 changed files with 72 additions and 25 deletions

View File

@ -6,3 +6,4 @@ taht
referer referer
referers referers
statics statics
firs

View File

@ -212,6 +212,15 @@
payload.h = 1 payload.h = 1
{{/if}} {{/if}}
{{#if pageleave}}
if (isPageview) {
currentPageLeaveIgnored = false
currentPageLeaveURL = payload.u
currentPageLeaveProps = payload.p
registerPageLeaveListener()
}
{{/if}}
var request = new XMLHttpRequest(); var request = new XMLHttpRequest();
request.open('POST', endpoint, true); request.open('POST', endpoint, true);
request.setRequestHeader('Content-Type', 'text/plain'); request.setRequestHeader('Content-Type', 'text/plain');
@ -220,14 +229,6 @@
request.onreadystatechange = function() { request.onreadystatechange = function() {
if (request.readyState === 4) { if (request.readyState === 4) {
{{#if pageleave}}
if (isPageview) {
currentPageLeaveIgnored = false
currentPageLeaveURL = payload.u
currentPageLeaveProps = payload.p
registerPageLeaveListener()
}
{{/if}}
options && options.callback && options.callback({status: request.status}) options && options.callback && options.callback({status: request.status})
} }
} }
@ -246,7 +247,7 @@
{{#unless hash}} {{#unless hash}}
if (lastPage === location.pathname) return; if (lastPage === location.pathname) return;
{{/unless}} {{/unless}}
{{#if pageleave}} {{#if pageleave}}
if (isSPANavigation && listeningPageLeave) { if (isSPANavigation && listeningPageLeave) {
triggerPageLeave(); triggerPageLeave();

View File

@ -45,7 +45,7 @@ test.describe('file-downloads extension', () => {
await page.goto('/file-download.html') await page.goto('/file-download.html')
const downloadURL = LOCAL_SERVER_ADDR + '/' + await page.locator('#local-download').getAttribute('href') const downloadURL = LOCAL_SERVER_ADDR + '/' + await page.locator('#local-download').getAttribute('href')
const downloadRequestMockList = mockManyRequests(page, downloadURL, 2) const downloadRequestMockList = mockManyRequests({ page, path: downloadURL, numberOfRequests: 2 })
await page.click('#local-download') await page.click('#local-download')
expect((await downloadRequestMockList).length).toBe(1) expect((await downloadRequestMockList).length).toBe(1)

View File

@ -26,16 +26,16 @@
<script> <script>
document.addEventListener('click', (e) => { document.addEventListener('click', (e) => {
if (e.target.id === 'pageview-trigger') { if (e.target.id === 'pageview-trigger') {
window.plausible('pageview') window.plausible('pageview')
} }
if (e.target.id === 'pageview-trigger-custom-url') { if (e.target.id === 'pageview-trigger-custom-url') {
window.plausible('pageview', {u: 'https://example.com/custom/location'}) window.plausible('pageview', {u: 'https://example.com/custom/location'})
} }
if (e.target.id === 'custom-event-trigger') { if (e.target.id === 'custom-event-trigger') {
window.plausible('CustomEvent') window.plausible('CustomEvent')
} }
if (e.target.id === 'custom-event-trigger-custom-url') { if (e.target.id === 'custom-event-trigger-custom-url') {
window.plausible('CustomEvent', {u: 'https://example.com/custom/location'}) window.plausible('CustomEvent', {u: 'https://example.com/custom/location'})
} }
}) })
</script> </script>

View File

@ -11,6 +11,14 @@
<body> <body>
<a id="navigate-away" href="/manual.html">Navigate away</a> <a id="navigate-away" href="/manual.html">Navigate away</a>
<button id="back-button-trigger">Back button</button>
</body> </body>
<script>
document.addEventListener('click', (e) => {
if (e.target.id === 'back-button-trigger') {
window.history.back()
}
})
</script>
</html> </html>

View File

@ -11,6 +11,7 @@
<body> <body>
<a id="navigate-away" href="/manual.html">Navigate away</a> <a id="navigate-away" href="/manual.html">Navigate away</a>
<a id="to-pageleave-pageview-props" href="/pageleave-pageview-props.html">Navigate to pageleave-pageview props</a>
<button id="history-nav">Navigate with history</button> <button id="history-nav">Navigate with history</button>

View File

@ -128,7 +128,7 @@ test.describe('pageleave extension', () => {
action: () => page.goto('/pageleave-pageview-props.html'), action: () => page.goto('/pageleave-pageview-props.html'),
expectedRequests: [{n: 'pageview', p: {author: 'John'}}] expectedRequests: [{n: 'pageview', p: {author: 'John'}}]
}) })
await expectPlausibleInAction(page, { await expectPlausibleInAction(page, {
action: () => page.click('#navigate-away'), action: () => page.click('#navigate-away'),
expectedRequests: [{n: 'pageleave', p: {author: 'John'}}] expectedRequests: [{n: 'pageleave', p: {author: 'John'}}]
@ -148,9 +148,9 @@ test.describe('pageleave extension', () => {
{n: 'pageview', p: {author: 'john'}} {n: 'pageview', p: {author: 'john'}}
] ]
}) })
await pageleaveCooldown(page) await pageleaveCooldown(page)
await expectPlausibleInAction(page, { await expectPlausibleInAction(page, {
action: () => page.click('#jane-post'), action: () => page.click('#jane-post'),
expectedRequests: [ expectedRequests: [
@ -169,4 +169,25 @@ test.describe('pageleave extension', () => {
] ]
}) })
}) })
})
test('sends a pageleave when plausible API is slow and user navigates away before response is received', async ({ page }) => {
await expectPlausibleInAction(page, {
action: () => page.goto('/pageleave.html'),
expectedRequests: [{n: 'pageview', u: `${LOCAL_SERVER_ADDR}/pageleave.html`}]
})
await expectPlausibleInAction(page, {
action: async () => {
await page.click('#to-pageleave-pageview-props')
await page.click('#back-button-trigger')
},
expectedRequests: [
{n: 'pageleave', u: `${LOCAL_SERVER_ADDR}/pageleave.html`},
{n: 'pageview', u: `${LOCAL_SERVER_ADDR}/pageleave-pageview-props.html`, p: {author: 'John'}},
{n: 'pageleave', u: `${LOCAL_SERVER_ADDR}/pageleave-pageview-props.html`, p: {author: 'John'}},
{n: 'pageview', u: `${LOCAL_SERVER_ADDR}/pageleave.html`}
],
responseDelay: 1000
})
})
})

View File

@ -32,13 +32,16 @@ exports.metaKey = function() {
// Mocks a specified number of HTTP requests with given path. Returns a promise that resolves to a // Mocks a specified number of HTTP requests with given path. Returns a promise that resolves to a
// list of requests as soon as the specified number of requests is made, or 3 seconds has passed. // list of requests as soon as the specified number of requests is made, or 3 seconds has passed.
const mockManyRequests = function(page, path, numberOfRequests) { const mockManyRequests = function({ page, path, numberOfRequests, responseDelay }) {
return new Promise((resolve, _reject) => { return new Promise((resolve, _reject) => {
let requestList = [] let requestList = []
const requestTimeoutTimer = setTimeout(() => resolve(requestList), 3000) const requestTimeoutTimer = setTimeout(() => resolve(requestList), 3000)
page.route(path, (route, request) => { page.route(path, async (route, request) => {
requestList.push(request) requestList.push(request)
if (responseDelay) {
await delay(responseDelay)
}
if (requestList.length === numberOfRequests) { if (requestList.length === numberOfRequests) {
clearTimeout(requestTimeoutTimer) clearTimeout(requestTimeoutTimer)
resolve(requestList) resolve(requestList)
@ -53,14 +56,14 @@ exports.mockManyRequests = mockManyRequests
/** /**
* A powerful utility function that makes it easy to assert on the event * A powerful utility function that makes it easy to assert on the event
* requests that should or should not have been made after doing a page * requests that should or should not have been made after doing a page
* action (e.g. navigating to the page, clicking a page element, etc). * action (e.g. navigating to the page, clicking a page element, etc).
* *
* @param {Page} page - The Playwright Page object. * @param {Page} page - The Playwright Page object.
* @param {Object} args - The object configuring the action and related expectations. * @param {Object} args - The object configuring the action and related expectations.
* @param {Function} args.action - A function that returns a promise. The function is called * @param {Function} args.action - A function that returns a promise. The function is called
* without arguments, and is `await`ed. This is the action that should or should not trigger * without arguments, and is `await`ed. This is the action that should or should not trigger
* Plausible requests on the page. * Plausible requests on the page.
* @param {Array} [args.expectedRequests] - A list of partial JSON payloads that get matched * @param {Array} [args.expectedRequests] - A list of partial JSON payloads that get matched
* against the bodies of event requests made. An `expectedRequest` is considered as having * against the bodies of event requests made. An `expectedRequest` is considered as having
* occurred if all of its key-value pairs are found from the JSON body of an event request * occurred if all of its key-value pairs are found from the JSON body of an event request
* that was made. The default value is `[]` * that was made. The default value is `[]`
@ -73,18 +76,26 @@ exports.mockManyRequests = mockManyRequests
* is `expectedRequests.length + refutedRequests.length`. * is `expectedRequests.length + refutedRequests.length`.
* @param {number} [args.expectedRequestCount] - When provided, expects the total amount of * @param {number} [args.expectedRequestCount] - When provided, expects the total amount of
* event requests made to match this number. * event requests made to match this number.
* @param {number} [args.responseDelay] - When provided, delays the response from the Plausible
* API by the given number of milliseconds.
*/ */
exports.expectPlausibleInAction = async function (page, { exports.expectPlausibleInAction = async function (page, {
action, action,
expectedRequests = [], expectedRequests = [],
refutedRequests = [], refutedRequests = [],
awaitedRequestCount, awaitedRequestCount,
expectedRequestCount expectedRequestCount,
responseDelay
}) { }) {
const requestsToExpect = expectedRequestCount ? expectedRequestCount : expectedRequests.length const requestsToExpect = expectedRequestCount ? expectedRequestCount : expectedRequests.length
const requestsToAwait = awaitedRequestCount ? awaitedRequestCount : requestsToExpect + refutedRequests.length const requestsToAwait = awaitedRequestCount ? awaitedRequestCount : requestsToExpect + refutedRequests.length
const plausibleRequestMockList = mockManyRequests(page, '/api/event', requestsToAwait) const plausibleRequestMockList = mockManyRequests({
page,
path: '/api/event',
numberOfRequests: requestsToAwait,
responseDelay: responseDelay
})
await action() await action()
const requestBodies = (await plausibleRequestMockList).map(r => r.postDataJSON()) const requestBodies = (await plausibleRequestMockList).map(r => r.postDataJSON())
@ -113,7 +124,7 @@ exports.expectPlausibleInAction = async function (page, {
const refutedBodySubsetsErrorMessage = `The following requests were made, but were not expected:\n\n${JSON.stringify(refutedButFoundRequestBodies, null, 4)}` const refutedBodySubsetsErrorMessage = `The following requests were made, but were not expected:\n\n${JSON.stringify(refutedButFoundRequestBodies, null, 4)}`
expect(refutedButFoundRequestBodies, refutedBodySubsetsErrorMessage).toHaveLength(0) expect(refutedButFoundRequestBodies, refutedBodySubsetsErrorMessage).toHaveLength(0)
expect(requestBodies.length).toBe(requestsToExpect) expect(requestBodies.length).toBe(requestsToExpect)
} }
@ -137,3 +148,7 @@ function areFlatObjectsEqual(obj1, obj2) {
return keys1.every(key => obj2[key] === obj1[key]) return keys1.every(key => obj2[key] === obj1[key])
} }
function delay(ms) {
return new Promise(resolve => setTimeout(resolve, ms))
}