diff --git a/README.md b/README.md index 5bebb00..36cc0e7 100644 --- a/README.md +++ b/README.md @@ -56,6 +56,7 @@ const plausible = Plausible({ | hashMode | `bool` | Enables tracking based on URL hash changes. | `false` | | trackLocalhost | `bool` | Enables tracking on *localhost*. | `false` | | apiHost | `string` | Plausible's API host to use. Change this if you are self-hosting. | `'https://plausible.io'` | +| useSendBeacon | `bool` | Enables the use of `navigator.sendBeacon` method to send events. | `false` | The object returned from `Plausible()` contains the functions that you'll use to track your events. These functions are: @@ -207,7 +208,7 @@ You can also track all clicks to outbound links using `enableAutoOutboundTrackin For details on how to setup the tracking, visit the [docs](https://docs.plausible.io/outbound-link-click-tracking). -This function adds a `click` event listener to all `a` tags on the page and reports them to Plausible. It also creates a [MutationObserver](https://developer.mozilla.org/en-US/docs/Web/API/MutationObserver) that efficiently tracks node mutations, so dynamically-added links are also tracked. +This function adds a `click` event listener to all `a` tags on the page and reports them to Plausible. It also creates a [MutationObserver](https://developer.mozilla.org/en-US/docs/Web/API/MutationObserver) that efficiently tracks node mutations, so dynamically-added links are also tracked. Event is sent with `navigator.sendBeacon` method. ```ts import Plausible from 'plausible-tracker' diff --git a/src/lib/request.spec.ts b/src/lib/request.spec.ts index 4016de0..c0255e2 100644 --- a/src/lib/request.spec.ts +++ b/src/lib/request.spec.ts @@ -19,9 +19,17 @@ let xhrMockClass: ReturnType; const xmr = jest.spyOn(window, 'XMLHttpRequest'); +Object.assign(navigator, { + sendBeacon() { + // just making node aware this function exists + }, +}); +const sendBeacon = jest.spyOn(navigator, 'sendBeacon'); + const defaultData: Required = { hashMode: false, trackLocalhost: false, + useSendBeacon: false, url: 'https://my-app.com/my-url', domain: 'my-app.com', referrer: null, @@ -78,6 +86,25 @@ describe('sendEvent', () => { expect(xhrMockClass.send).toHaveBeenCalledWith(JSON.stringify(payload)); }); + test('sends via Navigator#sendBeacon', () => { + expect(sendBeacon).not.toHaveBeenCalled(); + sendEvent('myEvent', { ...defaultData, useSendBeacon: true }); + expect(sendBeacon).toHaveBeenCalledTimes(1); + + const payload = { + n: 'myEvent', + u: defaultData.url, + d: defaultData.domain, + r: defaultData.referrer, + w: defaultData.deviceWidth, + h: 0, + }; + + expect(sendBeacon).toHaveBeenCalledWith( + `${defaultData.apiHost}/api/event`, + JSON.stringify(payload) + ); + }); test('hash mode', () => { expect(xmr).not.toHaveBeenCalled(); sendEvent('myEvent', { ...defaultData, hashMode: true }); diff --git a/src/lib/request.ts b/src/lib/request.ts index e7c5f2a..2e91f97 100644 --- a/src/lib/request.ts +++ b/src/lib/request.ts @@ -13,10 +13,10 @@ type EventPayload = { readonly p?: string; }; -// eslint-disable-next-line functional/no-mixed-type export type EventOptions = { /** * Callback called when the event is successfully sent. + * Does not work with `useSendBeacon = true`. */ readonly callback?: () => void; /** @@ -68,15 +68,19 @@ export function sendEvent( p: options && options.props ? JSON.stringify(options.props) : undefined, }; - const req = new XMLHttpRequest(); - req.open('POST', `${data.apiHost}/api/event`, true); - req.setRequestHeader('Content-Type', 'text/plain'); - req.send(JSON.stringify(payload)); - // eslint-disable-next-line functional/immutable-data - req.onreadystatechange = () => { - if (req.readyState !== 4) return; - if (options && options.callback) { - options.callback(); - } - }; + if (!data?.useSendBeacon) { + const req = new XMLHttpRequest(); + req.open('POST', `${data.apiHost}/api/event`, true); + req.setRequestHeader('Content-Type', 'text/plain'); + req.send(JSON.stringify(payload)); + // eslint-disable-next-line functional/immutable-data + req.onreadystatechange = () => { + if (req.readyState !== 4) return; + if (options && options.callback) { + options.callback(); + } + }; + } else { + navigator.sendBeacon(`${data.apiHost}/api/event`, JSON.stringify(payload)); + } } diff --git a/src/lib/tracker.spec.ts b/src/lib/tracker.spec.ts index 95dd922..e250706 100644 --- a/src/lib/tracker.spec.ts +++ b/src/lib/tracker.spec.ts @@ -13,6 +13,7 @@ describe('tracker', () => { const getDefaultData: () => Required = () => ({ hashMode: false, trackLocalhost: false, + useSendBeacon: false, url: location.href, domain: location.hostname, referrer: document.referrer || null, @@ -23,6 +24,7 @@ describe('tracker', () => { const getCustomData: () => Required = () => ({ hashMode: true, trackLocalhost: true, + useSendBeacon: false, url: 'https://my-url.com', domain: 'my-domain.com', referrer: 'my-referrer', diff --git a/src/lib/tracker.ts b/src/lib/tracker.ts index a0a6aa9..d05cb3c 100644 --- a/src/lib/tracker.ts +++ b/src/lib/tracker.ts @@ -23,6 +23,11 @@ export type PlausibleInitOptions = { * Defaults to `'https://plausible.io'` */ readonly apiHost?: string; + /** + * Whether to use `Navigator#sendBeacon`. + * Defaults to `false`. + */ + readonly useSendBeacon?: boolean; }; /** @@ -223,6 +228,7 @@ export default function Plausible( referrer: document.referrer || null, deviceWidth: window.innerWidth, apiHost: 'https://plausible.io', + useSendBeacon: false, ...defaults, }); @@ -276,26 +282,8 @@ export default function Plausible( attributeFilter: ['href'], } ) => { - function trackClick(this: HTMLAnchorElement, event: MouseEvent) { - trackEvent('Outbound Link: Click', { props: { url: this.href } }); - - /* istanbul ignore next */ - // eslint-disable-next-line @typescript-eslint/ban-ts-comment - // @ts-ignore - if ( - !( - typeof process !== 'undefined' && - process && - process.env.NODE_ENV === 'test' - ) - ) { - setTimeout(() => { - // eslint-disable-next-line functional/immutable-data - location.href = this.href; - }, 150); - } - - event.preventDefault(); + function trackClick(this: HTMLAnchorElement, _: MouseEvent) { + trackEvent('Outbound Link: Click', { props: { url: this.href } }, { useSendBeacon: true }); } // eslint-disable-next-line functional/prefer-readonly-type