Skip to content
Draft
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
60 changes: 51 additions & 9 deletions packages/plugin-autocapture-browser/src/autocapture-plugin.ts
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,7 @@ import { VERSION } from './version';
import * as constants from './constants';
import {
createShouldTrackEvent,
getPageEndEventName,
type ElementBasedTimestampedEvent,
type TimestampedEvent,
type NavigateEvent,
Expand Down Expand Up @@ -141,7 +142,7 @@ export const autocapturePlugin = (

const dataExtractor = new DataExtractor(options, context);

// Page-level state shared across trackers, emitted in a single Page View End event on beforeunload
// Page-level state shared across trackers, emitted in a single Page View End event on page exit
// elementExposedForPage holds the total set of elements seen during the entire page view lifetime
const elementExposedForPage = new Set<string>();
// currentElementExposed only holds the set of elements that will be flushed during the next [Amplitude] Viewport Content Updated event
Expand Down Expand Up @@ -351,11 +352,19 @@ export const autocapturePlugin = (
if (isPageEnd && pageViewEndFired) {
return;
}
setTimeout(() => {
pageViewEndFired = false;
}, 100);

pageViewEndFired = true;
// Only page-end triggers arm the short dedupe window. A single page exit can surface as
// several events in quick succession (visibilitychange -> pagehide, or navigate + pagehide),
// and this collapses them into one page-end. Mid-page flushes (isPageEnd === false) must not
// arm it, otherwise they would suppress the real page-end (and its state reset) that follows
// within the window.
if (isPageEnd) {
setTimeout(() => {
pageViewEndFired = false;
}, 100);
pageViewEndFired = true;
}

fireViewportContentUpdated({
amplitude,
scrollTracker,
Expand All @@ -382,14 +391,47 @@ export const autocapturePlugin = (
subscriptions.push(trackers.exposure);
}

const beforeUnloadHandler = () => {
handleViewportContentUpdated(true);
// Fire a Viewport Content Updated event and immediately flush it. track() only schedules a
// flush on a timer (flushIntervalMillis), which usually does not run before the document is
// torn down on exit, so the event would otherwise sit unsent in the local storage queue and
// only be replayed (with a stale timestamp) on the next page load, if ever. Flushing here
// starts the request (with keepalive) while the page is still alive.
const fireAndFlushViewportContentUpdated = (isPageEnd: boolean) => {
handleViewportContentUpdated(isPageEnd);
void amplitude.flush();
};

// Page exit: prefer `pagehide` over `beforeunload`. `pagehide` also fires on mobile teardown
// and bfcache eviction where `beforeunload` does not, and (unlike `beforeunload`) it does not
// make the page ineligible for the bfcache. Fall back to `beforeunload` only when `pagehide`
// is unavailable.
const pageEndHandler = () => {
fireAndFlushViewportContentUpdated(true);
};
const pageEndEventName = getPageEndEventName(globalScope);
/* istanbul ignore next */
globalScope?.addEventListener(pageEndEventName, pageEndHandler);

// `visibilitychange` -> hidden is the last signal reliably delivered before a tab is
// backgrounded and possibly discarded without ever firing `pagehide` (common on mobile).
// Flush the pending batch here as a mid-page checkpoint (isPageEnd === false) so page-level
// state is preserved if the user returns; a real page end shortly after is collapsed by the
// page-end dedupe window in handleViewportContentUpdated.
const visibilityChangeHandler = () => {
/* istanbul ignore next */
if (globalScope?.document?.visibilityState !== 'hidden') {
return;
}
fireAndFlushViewportContentUpdated(false);
};
/* istanbul ignore next */
globalScope?.addEventListener('beforeunload', beforeUnloadHandler);
globalScope?.document?.addEventListener('visibilitychange', visibilityChangeHandler);

beforeUnloadCleanup = () => {
/* istanbul ignore next */
globalScope?.removeEventListener('beforeunload', beforeUnloadHandler);
globalScope?.removeEventListener(pageEndEventName, pageEndHandler);
/* istanbul ignore next */
globalScope?.document?.removeEventListener('visibilitychange', visibilityChangeHandler);
};
// Ensure cleanup on teardown as well
subscriptions.push({ unsubscribe: () => beforeUnloadCleanup() });
Expand Down
10 changes: 10 additions & 0 deletions packages/plugin-autocapture-browser/src/helpers.ts
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,16 @@ const SENSITIVE_TAGS = ['input', 'select', 'textarea'];

export type shouldTrackEvent = (actionType: ActionType, element: Element) => boolean;

/**
* Resolves the event to listen to for page-exit flushing. Prefers `pagehide`, which (unlike
* `beforeunload`) also fires on mobile teardown and bfcache eviction and does not make the page
* ineligible for the bfcache. Falls back to `beforeunload` only when `pagehide` is unavailable.
*/
export const getPageEndEventName = (scope: typeof globalThis | undefined): 'pagehide' | 'beforeunload' => {
const scopeSelf = scope?.self;
return scopeSelf && 'onpagehide' in scopeSelf ? 'pagehide' : 'beforeunload';
};

export const isElementPointerCursor = (element: Element, actionType: ActionType): boolean => {
/* istanbul ignore next */
const computedStyle = window?.getComputedStyle?.(element);
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -115,8 +115,8 @@ describe('autocapturePlugin - Viewport Content Updated (Exposure)', () => {
// Should not trigger again immediately
expect(fireViewportContentUpdatedMock).not.toHaveBeenCalled();

// But if we trigger page view end (e.g. via beforeunload), it should flush the new batch
window.dispatchEvent(new Event('beforeunload'));
// But if we trigger page view end (e.g. via pagehide), it should flush the new batch
window.dispatchEvent(new Event('pagehide'));

expect(fireViewportContentUpdatedMock).toHaveBeenCalledTimes(1);
expect(track).toHaveBeenCalledWith(
Expand All @@ -134,8 +134,8 @@ describe('autocapturePlugin - Viewport Content Updated (Exposure)', () => {
// 2. Add the same element again
onExposureCallback('element-1');

// 3. Force flush via beforeunload
window.dispatchEvent(new Event('beforeunload'));
// 3. Force flush via pagehide
window.dispatchEvent(new Event('pagehide'));

// Should only be in the array once
expect(track).toHaveBeenCalledWith(
Expand All @@ -153,7 +153,7 @@ describe('autocapturePlugin - Viewport Content Updated (Exposure)', () => {
);

onExposureCallback('element-1');
window.dispatchEvent(new Event('beforeunload'));
window.dispatchEvent(new Event('pagehide'));

expect(track).toHaveBeenCalledWith(
'[Amplitude] Viewport Content Updated',
Expand All @@ -168,7 +168,7 @@ describe('autocapturePlugin - Viewport Content Updated (Exposure)', () => {
window.sessionStorage.setItem(constants.PAGE_VIEW_SESSION_STORAGE_KEY, 'invalid-json{not-valid');

onExposureCallback('element-1');
window.dispatchEvent(new Event('beforeunload'));
window.dispatchEvent(new Event('pagehide'));

expect(track).toHaveBeenCalledWith(
'[Amplitude] Viewport Content Updated',
Expand All @@ -182,9 +182,9 @@ describe('autocapturePlugin - Viewport Content Updated (Exposure)', () => {
expect(trackCall[1]).not.toHaveProperty('[Amplitude] Page View ID');
});

test('should call handleViewportContentUpdated with isPageEnd=true on beforeunload', async () => {
test('should call handleViewportContentUpdated with isPageEnd=true on pagehide', async () => {
onExposureCallback('element-1');
window.dispatchEvent(new Event('beforeunload'));
window.dispatchEvent(new Event('pagehide'));

expect(fireViewportContentUpdatedMock).toHaveBeenCalledTimes(1);
expect(fireViewportContentUpdatedMock).toHaveBeenCalledWith(
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -183,7 +183,7 @@ describe('autoTrackingPlugin', () => {
jest.advanceTimersByTime(100);

// Trigger page end to flush the exposure
window.dispatchEvent(new Event('beforeunload'));
window.dispatchEvent(new Event('pagehide'));

expect(track).toHaveBeenCalledWith(
'[Amplitude] Viewport Content Updated',
Expand Down Expand Up @@ -248,7 +248,7 @@ describe('autoTrackingPlugin', () => {

jest.advanceTimersByTime(100);

window.dispatchEvent(new Event('beforeunload'));
window.dispatchEvent(new Event('pagehide'));

expect(track).toHaveBeenCalledWith(
'[Amplitude] Viewport Content Updated',
Expand Down Expand Up @@ -315,7 +315,7 @@ describe('autoTrackingPlugin', () => {

// Should fire after nestedDuration (not flatDuration)
jest.advanceTimersByTime(100);
window.dispatchEvent(new Event('beforeunload'));
window.dispatchEvent(new Event('pagehide'));

expect(track).toHaveBeenCalledWith(
'[Amplitude] Viewport Content Updated',
Expand Down Expand Up @@ -354,7 +354,7 @@ describe('autoTrackingPlugin', () => {

await plugin?.setup?.(config as BrowserConfig, amplitude);

window.dispatchEvent(new Event('beforeunload'));
window.dispatchEvent(new Event('pagehide'));

expect(track).not.toHaveBeenCalledWith('[Amplitude] Viewport Content Updated', expect.anything());

Expand Down Expand Up @@ -1765,14 +1765,15 @@ describe('autoTrackingPlugin', () => {
(window as any).IntersectionObserver = undefined;
});

test('should track [Amplitude] Viewport Content Updated on beforeunload', async () => {
test('should track and flush [Amplitude] Viewport Content Updated on pagehide', async () => {
const config: Partial<BrowserConfig> = {
defaultTracking: false,
loggerProvider: loggerProvider,
};
const flush = jest.spyOn(instance, 'flush').mockImplementation(jest.fn());
await plugin?.setup?.(config as BrowserConfig, instance);

window.dispatchEvent(new Event('beforeunload'));
window.dispatchEvent(new Event('pagehide'));

expect(track).toHaveBeenCalledWith(
'[Amplitude] Viewport Content Updated',
Expand All @@ -1782,21 +1783,57 @@ describe('autoTrackingPlugin', () => {
'[Amplitude] Viewport Width': expect.any(Number),
}),
);
// The event must be flushed synchronously on exit; the interval flush would not run
// before the document is torn down.
expect(flush).toHaveBeenCalled();
});

test('should not track duplicate [Amplitude] Viewport Content Updated events on multiple beforeunload', async () => {
test('should not track duplicate [Amplitude] Viewport Content Updated events on multiple pagehide', async () => {
const config: Partial<BrowserConfig> = {
defaultTracking: false,
loggerProvider: loggerProvider,
};
await plugin?.setup?.(config as BrowserConfig, instance);

window.dispatchEvent(new Event('beforeunload'));
window.dispatchEvent(new Event('beforeunload'));
window.dispatchEvent(new Event('pagehide'));
window.dispatchEvent(new Event('pagehide'));

expect(track).toHaveBeenCalledTimes(1);
});

test('should track and flush [Amplitude] Viewport Content Updated on visibilitychange to hidden', async () => {
const config: Partial<BrowserConfig> = {
defaultTracking: false,
loggerProvider: loggerProvider,
};
const flush = jest.spyOn(instance, 'flush').mockImplementation(jest.fn());
await plugin?.setup?.(config as BrowserConfig, instance);

// Change scroll depth so there is new content to report on the visibility checkpoint.
Object.defineProperty(window, 'scrollY', { value: 50, writable: true });
Object.defineProperty(window, 'pageYOffset', { value: 50, writable: true });
window.dispatchEvent(new Event('scroll'));

Object.defineProperty(document, 'visibilityState', { value: 'hidden', writable: true, configurable: true });
document.dispatchEvent(new Event('visibilitychange'));

expect(track).toHaveBeenCalledWith('[Amplitude] Viewport Content Updated', expect.any(Object));
expect(flush).toHaveBeenCalled();
});

test('should not track [Amplitude] Viewport Content Updated on visibilitychange to visible', async () => {
const config: Partial<BrowserConfig> = {
defaultTracking: false,
loggerProvider: loggerProvider,
};
await plugin?.setup?.(config as BrowserConfig, instance);

Object.defineProperty(document, 'visibilityState', { value: 'visible', writable: true, configurable: true });
document.dispatchEvent(new Event('visibilitychange'));

expect(track).not.toHaveBeenCalled();
});

test('should track [Amplitude] Viewport Content Updated on history.pushState and reset state', async () => {
const config: Partial<BrowserConfig> = {
defaultTracking: false,
Expand Down Expand Up @@ -1915,7 +1952,7 @@ describe('autoTrackingPlugin', () => {
window.dispatchEvent(new Event('scroll'));

// Trigger page view end
window.dispatchEvent(new Event('beforeunload'));
window.dispatchEvent(new Event('pagehide'));

expect(track).toHaveBeenCalledWith(
'[Amplitude] Viewport Content Updated',
Expand Down
17 changes: 17 additions & 0 deletions packages/plugin-autocapture-browser/test/helpers.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@ import {
asyncLoadScript,
generateUniqueId,
createShouldTrackEvent,
getPageEndEventName,
} from '../src/helpers';
import { autocapturePlugin } from '../src/autocapture-plugin';
import { mockWindowLocationFromURL } from './utils';
Expand Down Expand Up @@ -692,4 +693,20 @@ describe('autocapture-plugin helpers', () => {
expect(shouldTrackEvent('click', element)).toEqual(true);
});
});

describe('getPageEndEventName', () => {
test('should return pagehide when the scope supports it', () => {
const scope = { self: { onpagehide: null } } as unknown as typeof globalThis;
expect(getPageEndEventName(scope)).toEqual('pagehide');
});

test('should fall back to beforeunload when pagehide is unsupported', () => {
const scope = { self: {} } as unknown as typeof globalThis;
expect(getPageEndEventName(scope)).toEqual('beforeunload');
});

test('should fall back to beforeunload when the scope is undefined', () => {
expect(getPageEndEventName(undefined)).toEqual('beforeunload');
});
});
});
Loading