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
39 changes: 39 additions & 0 deletions packages/analytics-browser/e2e/form-interactions.spec.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,39 @@
/* eslint-disable @typescript-eslint/no-unsafe-return */
import { test, expect } from '@playwright/test';

const FORM_STARTED_EVENT = '[Amplitude] Form Started';

test.describe('Form Interactions Page', () => {
let requests: any[] = [];

test.beforeEach(async ({ page }) => {
requests = [];
await page.route('https://api2.amplitude.com/2/httpapi', async (route) => {
const request = route.request();
const postData = request.postData();
if (postData) {
const data = JSON.parse(postData);
requests.push(data);
}
await route.continue();
});
});

test('should track form started events', async ({ page }) => {
await page.goto('/form-interactions/form-interactions.html');
await page.waitForLoadState('networkidle');

// Trigger form started by changing a form field (first interaction) and then navigate away to trigger pagehide/beforeunload → form abandoned
await page.fill('#name', 'Test User');
await page.fill('#email', 'test@example.com');
// mock dispatch pagehide event
await page.evaluate(() => window.dispatchEvent(new Event('beforeunload')));
await page.waitForTimeout(2000);

// Wait for the form started event to be sent
await expect(async () => {
const allEvents = requests.flatMap((r) => r.events || []).filter((e: any) => e.event_type !== '$identify');
expect(allEvents.some((e: any) => e.event_type === FORM_STARTED_EVENT)).toBe(true);
}).toPass({ timeout: 5000 });
});
});
1 change: 1 addition & 0 deletions packages/analytics-browser/src/constants.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@ export const DEFAULT_EVENT_PREFIX = '[Amplitude]';
export const DEFAULT_PAGE_VIEW_EVENT = `${DEFAULT_EVENT_PREFIX} Page Viewed`;
export const DEFAULT_FORM_START_EVENT = `${DEFAULT_EVENT_PREFIX} Form Started`;
export const DEFAULT_FORM_SUBMIT_EVENT = `${DEFAULT_EVENT_PREFIX} Form Submitted`;
export const DEFAULT_FORM_ABANDONED_EVENT = `${DEFAULT_EVENT_PREFIX} Form Abandoned`;
export const DEFAULT_FILE_DOWNLOAD_EVENT = `${DEFAULT_EVENT_PREFIX} File Downloaded`;
export const DEFAULT_SESSION_START_EVENT = 'session_start';
export const DEFAULT_SESSION_END_EVENT = 'session_end';
Expand Down
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
import {
DEFAULT_FORM_START_EVENT,
DEFAULT_FORM_SUBMIT_EVENT,
DEFAULT_FORM_ABANDONED_EVENT,
FORM_ID,
FORM_NAME,
FORM_DESTINATION,
Expand All @@ -15,29 +16,33 @@ import {
} from '@amplitude/analytics-core';
import { getFormInteractionsConfig } from '../default-tracking';

type ElementOrWindow = Element | Pick<Window, 'addEventListener' | 'removeEventListener'>;

type EventType = 'change' | 'submit' | 'pagehide' | 'beforeunload';

interface EventListener {
element: Element;
type: 'change' | 'submit';
elementOrWindow: ElementOrWindow;
type: EventType;
handler: (event: Event) => void;
}

export const formInteractionTracking = (): EnrichmentPlugin => {
let observer: MutationObserver | undefined;
let eventListeners: EventListener[] = [];

const addEventListener = (element: Element, type: 'change' | 'submit', handler: (event: Event) => void) => {
element.addEventListener(type, handler);
const addEventListener = (elementOrWindow: ElementOrWindow, type: EventType, handler: (event: Event) => void) => {
elementOrWindow.addEventListener(type, handler);
eventListeners.push({
element,
elementOrWindow,
type,
handler,
});
};

const removeClickListeners = () => {
eventListeners.forEach(({ element, type, handler }) => {
eventListeners.forEach(({ elementOrWindow, type, handler }) => {
/* istanbul ignore next */
element?.removeEventListener(type, handler);
elementOrWindow?.removeEventListener(type, handler);
});
eventListeners = [];
};
Expand Down Expand Up @@ -72,17 +77,40 @@ export const formInteractionTracking = (): EnrichmentPlugin => {
addedFormNodes.add(form);

let hasFormChanged = false;
let hasFormAbandoned = false;

const trackFormAbandoned = () => {
if (hasFormChanged && !hasFormAbandoned) {
hasFormAbandoned = true;
const formDestination = extractFormAction(form);
amplitude.track(DEFAULT_FORM_ABANDONED_EVENT, {
[FORM_ID]: stringOrUndefined(form.id),
[FORM_NAME]: stringOrUndefined(form.name),
[FORM_DESTINATION]: formDestination,
});
}
// left the page indicating
hasFormChanged = false;
};
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

hasFormAbandoned never resets, blocking repeated abandon tracking

Medium Severity

hasFormAbandoned is set to true in trackFormAbandoned but is never reset to false. After an abandon event fires (e.g., beforeunload that gets canceled by another handler, or pagehide when the page enters bfcache and is later restored), the flag permanently prevents any future Form Abandoned events. Meanwhile, hasFormChanged is reset to false, so a subsequent form change triggers a new Form Started event — creating an asymmetry where forms can be "started" multiple times but only "abandoned" once. The change handler needs to reset hasFormAbandoned when re-engaging with the form.

Additional Locations (1)

Fix in Cursor Fix in Web


const win = getGlobalScope() as Pick<Window, 'addEventListener' | 'removeEventListener'>;

/* istanbul ignore if */
if (formInteractionsConfig?.shouldTrackAbandoned && win) {
addEventListener(win, 'pagehide', trackFormAbandoned);
addEventListener(win, 'beforeunload', trackFormAbandoned);
}
Comment thread
cursor[bot] marked this conversation as resolved.

addEventListener(form, 'change', () => {
const formDestination = extractFormAction(form);
if (!hasFormChanged) {
hasFormChanged = true;
const formDestination = extractFormAction(form);
amplitude.track(DEFAULT_FORM_START_EVENT, {
[FORM_ID]: stringOrUndefined(form.id),
[FORM_NAME]: stringOrUndefined(form.name),
[FORM_DESTINATION]: formDestination,
});
}
hasFormChanged = true;
});

addEventListener(form, 'submit', (event: Event) => {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -541,4 +541,51 @@ describe('formInteractionTracking', () => {
});
});
});

describe('shouldTrackAbandoned', () => {
beforeEach(async () => {
const config = createConfigurationMock({
defaultTracking: {
formInteractions: {
shouldTrackAbandoned: true,
},
},
});
const plugin = formInteractionTracking();
await plugin.setup?.(config, amplitude);
window.dispatchEvent(new Event('load'));
});

test('should track form abandoned when form started then abandoned (pagehide)', async () => {
document.getElementById('my-form-id')?.dispatchEvent(new Event('change'));
window.dispatchEvent(new Event('pagehide'));
expect(amplitude.track).toHaveBeenNthCalledWith(2, '[Amplitude] Form Abandoned', {
[FORM_ID]: 'my-form-id',
[FORM_NAME]: 'my-form-name',
[FORM_DESTINATION]: 'http://localhost/submit',
});
});

test('should track form abandoned when form started then abandoned (beforeunload)', async () => {
document.getElementById('my-form-id')?.dispatchEvent(new Event('change'));
window.dispatchEvent(new Event('beforeunload'));
expect(amplitude.track).toHaveBeenNthCalledWith(2, '[Amplitude] Form Abandoned', {
[FORM_ID]: 'my-form-id',
[FORM_NAME]: 'my-form-name',
[FORM_DESTINATION]: 'http://localhost/submit',
});
});

test('should track form abandoned when form started then abandoned (multiple times)', async () => {
document.getElementById('my-form-id')?.dispatchEvent(new Event('change'));
window.dispatchEvent(new Event('beforeunload'));
window.dispatchEvent(new Event('pagehide'));
expect(amplitude.track).toHaveBeenCalledTimes(2);
});

test('should not track form abandoned when form is not started', async () => {
window.dispatchEvent(new Event('pagehide'));
expect(amplitude.track).not.toHaveBeenCalled();
});
});
});
7 changes: 7 additions & 0 deletions packages/analytics-core/src/types/form-interactions.ts
Original file line number Diff line number Diff line change
Expand Up @@ -22,4 +22,11 @@ export interface FormInteractionsOptions {
* ```
*/
shouldTrackSubmit?: (event: SubmitEvent) => boolean;

/**
* Whether to track `[Amplitude] Form Abandoned` event
* @defaultValue `true`
* @experimental This feature is experimental and may not be stable
*/
shouldTrackAbandoned?: boolean;
}
47 changes: 47 additions & 0 deletions test-server/form-interactions/form-interactions.html
Original file line number Diff line number Diff line change
@@ -0,0 +1,47 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Form Interactions Test</title>
</head>
<body>
<h1>Form Interactions Test</h1>
<p>This page initializes Amplitude with autocapture.formInteractions enabled.</p>

<form name="contact" action="#" method="post">
<p>
<label for="name">Name</label>
<input type="text" id="name" name="name">
</p>
<p>
<label for="email">Email</label>
<input type="email" id="email" name="email">
</p>
<p>
<label for="message">Message</label>
<textarea id="message" name="message"></textarea>
</p>
<p>
<button type="submit">Submit</button>
</p>
</form>

<script type="module">
import * as amplitude from '@amplitude/analytics-browser';

window.amplitude = amplitude;

amplitude.init(import.meta.env.VITE_AMPLITUDE_API_KEY, undefined, {
fetchRemoteConfig: false,
autocapture: {
pageViews: false,
sessions: false,
formInteractions: {
shouldTrackAbandoned: true,
},
},
});
</script>
</body>
</html>
Loading