Skip to content

Instantly share code, notes, and snippets.

@isocroft
Last active May 25, 2025 09:36
Show Gist options
  • Save isocroft/c98871ae9333f29e2e09cd320335d918 to your computer and use it in GitHub Desktop.
Save isocroft/c98871ae9333f29e2e09cd320335d918 to your computer and use it in GitHub Desktop.
A form abandonment script for all forms on a given web page for SPA and non-SPAs: e.g. contact form, checkout form, signup form e.t.c.
;(function (window, document) {
/**
* Form Abandonment script created by @isocroft
*
* Copyright (c) February 2022-2025 | Ifeora Okechukwu
*
* Works in ReactJS / VueJS / jQuery / Angular / Vanilla
*
* This implements a simple algorithm that basically tracks when forms on a given HTML page
* has been abandoned by a user visiting the site and wanting or needing to fill a form.
*/
/* @HINT: feature detection for hidden document */
const getPageState = function () {
const hidden = (document.hidden || document.mozHidden || document.msHidden || document.webkitHidden);
if (hidden || document.visibilityState === 'hidden') {
return 'hidden';
}
if (document.hasFocus() || document.activeElement !== null) {
return 'active';
}
return 'passive';
};
let observer = null;
/* @HINT: copy out the form DOM element `submit` function... */
/* @HINT: ...using the DOM interface for the form DOM element */
/* @CHECK: https://codescracker.com/js/js-dom-interfaces.htm */
const formSubmitFn = window.HTMLFormElement.prototype.submit
Object.defineProperty(window.HTMLFormElement.prototype, 'submit', {
writable: false,
value: function () {
/* @HINT: This will fire the `submit` event when the submit method on the form is called */
/* @CHECK: https://developer.mozilla.org/en-US/docs/Web/API/HTMLFormElement/submit */
const event = new Event('submit', { bubbles: true, cancelable: true });
let result = undefined;
if (typeof this.onsubmit === 'function') {
result = this.onsubmit.bind(this, event)
}
if (!result && !event.defaultPrevented) {
return formSubmitFn.call(this);
}
}
})
/* @HINT: Define a writable property `isFilledOut` on the DOM interface prototype... */
/* @HINT: ...for the form. This property is used to flag a form as completely filled */
Object.defineProperty(window.HTMLFormElement.prototype, 'isFilledOut', {
enumerable: false,
writable: true,
value: false
})
/* @HINT: Define a writable property `isSubmitted` on the DOM interface prototype... */
/* @HINT: ...for the form. This property is used to flag a form as submitted */
Object.defineProperty(window.HTMLFormElement.prototype, 'isSubmitted', {
enumerable: false,
writable: true,
value: false
})
/* @HINT: Define a non-writable property `fillHistory` on the DOM interface prototype... */
/* @HINT: ...for the form. This property is used to list the fields of a form that are filled */
Object.defineProperty(window.HTMLFormElement.prototype, 'fillHistory', {
enumerable: false,
writable: false,
value: []
})
/* @HINT: Define a writable property `currentlyFocusedForm` on the DOM interface prototype... */
/* @HINT: ...for the form. This property is used to flag the form that is currently being filled */
Object.defineProperty(window.Document.prototype, 'currentlyFocusedForm', {
enumerable: false,
writable: true,
value: null
})
document.addEventListener('click', function globalClickHandler (e) {
/* @HINT: If an HTML anchor tag is clicked, manually trigger the `beforeunload` event as some browsers don't fire it */
if (e.target.tagName === "A") {
if (!e.defaultPrevented) {
event = new Event('beforeunload');
event.detail = { oldURL: document.URL, newURL: e.target.href }
return window.dispatchEvent(event);
}
}
return true;
}, false);
function setupForms (formsList = []) {
/* @HINT: Loop through all HTML forms and attach the `onsubmit` event handler property function to each one */
formsList.forEach(function formIterator (form) {
let $onsubmit = () => false
/* @HINT: if the `onsubmit` handler is set, copy it out so... */
/* @HINT: ...it can be fired later */
if (form.onsubmit !== null) {
$onsubmit = form.onsubmit;
/* @HINT: Set the `onsubmit` handler to a new custom property: `oldSubmitFn` so it is called later */
/* @USAGE: #LineNumber=463 */
form.oldSubmitFn = $onsubmit;
}
/* @HINT: If the form doesn't have an `id` or `name` property/attribute set ... */
if (!form.id && !form.name) {
/* @HINT: Then... generate a random `id` or `name` text (with letters only) and set it up */
const randomlyGeneratedText = ((((Math.random() / 0.95) + 1) * (new Date()).getTime()).toString(32)).replace(/([.\d])+/g, '');
form.id = randomlyGeneratedText;
form.name = randomlyGeneratedText;
}
form.onsubmit = onSubmit;
/* @HINT: Loop through all HTML form elements and attach the `change`, `focus`, `invalid` and `activate` ...
/* @HINT: ...events to each one */
onReady(form);
});
}
function onPageLoaded () {
/* @HINT: Get all HTML forms defined inside a HTML document (page) */
const allForms = window.Array.from(document.forms);
const options = {
childList: true,
subtree: true
};
/* @HINT: Observe the DOM for mutations (newly added forms) after page has loaded */
observer = new window.MutationObserver(observerCallback);
function observerCallback(mutations) {
mutations.forEach((mutation) => {
if (mutation.type === 'childList') {
const addedNodes = Array.from(mutation.addedNodes);
if (addedNodes.length > 0) {
setupForms(addedNodes.filter((node) => {
return node.tagName === "FORM";
}));
}
}
})
}
/* @HINT: Begin observing the DOM of the web page for changes or additions of one or more FORM */
observer.observe(document.body, options);
/* @HINT: Setup all forms on the web page for tracking */
setupForms(allForms);
}
function onPageHidden () {
const pageState = getPageState();
if (pageState === 'hidden') {
if (observer !== null) {
/* @HINT: Disconnect DOM mutation observer to avoid memory leaks */
/* @CHECK: https://github.com/whatwg/dom/issues/482#issuecomment-318225143 */
observer.disconnect();
}
}
}
document.addEventListener('visibilitychange', onPageHidden, false);
window.addEventListener('pagehide', onPageHidden, false);
if (document.readyState === 'complete' || document.readyState === 'interactive') {
onPageLoaded();
} else {
document.addEventListener('DOMContentLoaded', onPageLoaded, false);
}
function onInvalid (event) {
/* @HINT: when the `invalid` event fires, remove the name of the form element... */
/* @HINT: ...from the `fillHistory` */
const element = event.target
const form = element.form
const index = form.fillHistory.indexOf(element.name);
if (index !== -1) {
form.fillHistory.splice(index, 1);
}
}
function onDocChange (e) {
/* @HINT: Get the document visibility state */
const state = getPageState()
if (state !== 'passive') {
if ((e.target === window && (e.detail.oldURL !== e.detail.newURL)) || (e.target === document)) {
document.dispatchEvent(
new Event('beforeurlchange')
);
}
const allValues = Object.keys(
window.localStorage
).filter((key) => {
return key.startsWith('__form_abandoned_')
}).map((key) => {
const formsProcessedForTracking = JSON.parse(
window.sessionStorage.getItem(
'forms_processed_for_tracking'
)
)
const formsSubmittedBypassTracking = JSON.parse(
window.sessionStorage.getItem(
'forms_submitted_bypass_tracking'
)
);
const formNameIndex = key.indexOf(':');
const formName = key.substring(formNameIndex + 1);
const value = window.localStorage.getItem(key);
window.localStorage.removeItem(key);
if (!formsSubmittedBypassTracking.includes(formName)) {
if (!formsProcessedForTracking.includes(formName)) {
formsProcessedForTracking.push(formName)
window.sessionStorage.setItem(
'forms_processed_for_tracking',
JSON.stringify(
formsProcessedForTracking
)
)
}
}
return formsSubmittedBypassTracking.includes(formName) ? '' : value;
}).filter((value) => {
return value !== null || value !== '' || value !== undefined
})
/* @HINT: Ensure that the bfcache (page cache) works well after unloading the page */
window.removeEventListener('beforeunload', onDocChange, false);
/* @HINT: track form-abandonment to a private or paid public third-party analytics platform */
if (allValues.length > 0) {
/* @HINT: Fire custom event for client code so that it has oppourtunity to prevent default (stop user from leaving the form page) */
const defaultActionWasPrevented = !window.dispatchEvent(
new Event('promptuseronforms')
);
if (!defaultActionWasPrevented) {
setTimeout(() => {
/* @HINT: If Google Analytics script (GA 4) is installed on this web page ... */
if (window.ga) {
/* @HINT: Then... track to Google Analytics to track form abandonment data */
/* @CHECK: https://support.google.com/analytics/answer/11150547?hl=en */
ga('send', 'event', 'User Interactions', 'Unfinished Forms', '__form_abandonment', `[${allValues.join(',')}]`);
} else {
if (typeof window.__trackFACallback !== "function") {
navigator.sendBeacon(
window.location.origin + '/analytics/track/forms/abandoned',
`[${allValues.join(',')}]`
);
} else {
window.__trackFACallback(allValues);
}
}
}, 0);
} else {
/* @HINT: Stop the page from navigating away */
e.preventDefault();
/* @HINT: Make sure the prompt is shown on the web page when the web page finally unloads */
/* @CHECK: https://developer.mozilla.org/en-US/docs/Web/API/Window/beforeunload_event#browser_compatibility */
return '?';
}
}
/* @HINT: Make sure no prompt is shown on the web page when the web page finally unloads */
delete e['returnValue'];
return;
}
}
function onActivate (event) {
const element = event.target
const form = element.form
const index = form.fillHistory.indexOf(element.name)
/* @HINT: get the active element and try to determine the form from there */
if (document.activeElement !== null) {
if (document.activeElement.tagName === 'FORM') {
document.currentlyFocusedForm = document.activeElement
} else {
if (form.contains(document.activeElement)) {
document.currentlyFocusedForm = document.activeElement.form || document.currentlyFocusedForm;
if (!document.currentlyFocusedForm) {
if (document.activeElement.parentNode !== null) {
if (document.activeElement.parentNode.tagName === 'FORM') {
document.currentlyFocusedForm = document.activeElement.parentNode;
}
}
}
}
}
} else {
document.currentlyFocusedForm = form;
}
if (index !== -1) {
switch (element.type) {
case 'checkbox':
case 'radio':
if (!element.checked) {
/* @HINT: If a checkbox or radio element isn't checked, ... */
/* @HINT: ...remove it from the already filled history by the user */
form.fillHistory.splice(index, 1);
}
break;
default:
if ((element.tagName === 'INPUT'
&& (element.type !== 'hidden' || element.type !== 'reset' || element.type !== 'submit' || element.type !== 'image'))
|| element.tagName === 'TEXTAREA') {
if (element.value.length === 0) {
/* @HINT: If an input or textarea element isn't filled, then... */
/* @HINT: ...remove it from the already filled history by the user */
form.fillHistory.splice(index, 1);
}
} else if (element.tagName === 'SELECT') {
const options = element.options
const selectedIndex = element.selectedIndex
if (options[selectedIndex].value.length === 0) {
/* @HINT: If a select element isn't filled, then... */
/* @HINT: ...remove it from the already filled history by the user */
form.fillHistory.splice(index, 1);
}
}
break;
}
}
}
function onChange (event) {
const element = event.target
const form = element.form
const index = form.fillHistory.indexOf(element.name)
/* @HINT: get the active element and try to determine the form from there */
if (document.activeElement !== null) {
if (document.activeElement.tagName === 'FORM') {
document.currentlyFocusedForm = document.activeElement
} else {
if (form.contains(document.activeElement)) {
document.currentlyFocusedForm = document.activeElement.form || document.currentlyFocusedForm;
if (!document.currentlyFocusedForm) {
if (document.activeElement.parentNode !== null) {
if (document.activeElement.parentNode.tagName === 'FORM') {
document.currentlyFocusedForm = document.activeElement.parentNode;
}
}
}
}
}
} else {
document.currentlyFocusedForm = form;
}
if (index !== -1 || (event.type === 'focus' || element.autofocus)) {
/* @HINT: if the history list already contains the element ... */
/* @HINT: ...that triggered the `change` event then return control flow */
return;
}
switch (element.type) {
case 'checkbox':
case 'radio':
if (element.checked) {
/* @HINT: If a checkbox or radio input is checked, then... */
/* @HINT: ...add it an already filled by the user */
form.fillHistory.push(element.name);
}
break;
default:
if ((element.tagName === 'INPUT'
&& (element.type !== 'hidden' || element.type !== 'reset' || element.type !== 'submit' || element.type !== 'image'))
|| element.tagName === 'TEXTAREA') {
if (element.value.length > 0) {
/* @HINT: If an input or textarea element is filled, then... */
/* @HINT: ...add it to the already filled history by the user */
form.fillHistory.push(element.name);
}
} else if (element.tagName === 'SELECT') {
const options = element.options
const selectedIndex = element.selectedIndex
if (options[selectedIndex].value.length > 0) {
/* @HINT: If a select element isn't filled, then... */
/* @HINT: ...remove it from the already filled history by the user */
form.fillHistory.push(element.name);
}
}
break;
}
/* @NOTE: Exclude all hidden, image. reset, submit INPUT elements & all BUTTON elements in the form */
const elements = window.Array.from(document.currentlyFocusedForm.elements).filter(($element) => {
return $element.tagName !== 'BUTTON' && ($element.type !== 'hidden' || $element.type !== 'reset' || $element.type !== 'submit' || $element.type !== 'image');
});
/* @HINT: If all the form elements are filled, then mark it as filled out */
document.currentlyFocusedForm.isFilledOut = (form.fillHistory.length === elements.length);
const $event = new CustomEvent('beforeurlchange', {
detail: {
notfromnav: true
},
bubbles: false,
cancelable: true
});
document.dispatchEvent(
$event
);
}
function onSubmit (event) {
const form = event.target || this;
const formName = form.id || form.name;
/* @HINT: If this form is not the currently filled form, then make it so */
if (document.currentlyFocusedForm !== form) {
document.currentlyFocusedForm = form
}
/* @NOTE: Exclude all hidden, image. reset, submit INPUT elements & all BUTTON elements in the form */
const elements = window.Array.from(form.elements).filter((element) => {
return element.tagName !== 'BUTTON' && (element.type !== 'hidden' || element.type !== 'reset' || element.type !== 'submit' || element.type !== 'image');
});
/* @HINT: If all the form elements are filled, then mark it as filled out */
document.currentlyFocusedForm.isFilledOut = (form.fillHistory.length === elements.length);
document.currentlyFocusedForm.isSubmitted = !event.defaultPrevented;
const formsSubmittedBypassTracking = JSON.parse(
window.sessionStorage.getItem(
'forms_submitted_bypass_tracking'
)
);
/* @HINT: If the current user-focused form is completely filled ... */
if (document.currentlyFocusedForm.isFilledOut) {
/* @HINT: Then... mark this form as submitted in the web (local) `Storage` */
formsSubmittedBypassTracking.push(formName);
window.sessionStorage.setItem(
'forms_submitted_bypass_tracking',
JSON.stringify(formsSubmittedBypassTracking)
);
/* @HINT: If the number of forms submitted (which will bypass tracking) are equal to the number of all forms on the webpage... */
if (formsSubmittedBypassTracking.length === document.forms.length) {
/* @HINT: Then... remove the `beforeunload` event handler to... */
/* @HINT: ...stop tracking for form abandonment all together before it can ever fire */
window.removeEventListener('beforeunload', onDocChange, false);
}
}
/* @HINT: Once, the currently user-focused form is submitted, zero out the `fillHistory` so it's not tracked */
/* @NOTE: We also need to fire the `beforeurlchange` event handler so other things (clean up) can happen too */
document.dispatchEvent(
new Event('beforeurlchange')
);
/* @HINT: If the `oldSubmitFn` property was set initially to a function ... */
if (form.oldSubmitFn !== null && typeof form.oldSubmitFn === 'function') {
/* @HINT: Then... call it and any function that still needs to be called when the `submit` event is fired for this form */
form.oldSubmitFn(event);
/* @TODO: more call code below later */
}
}
function onReady (form) {
const elements = Array.from(form.elements);
/* @HINT: Prepare all elements on the form for tracking */
elements.forEach(function elementsIterator (element) {
if (element.tagName !== 'BUTTON'
&& (element.type !== 'hidden' || element.type !== 'reset' || element.type !== 'submit' || element.type !== 'image')) {
/* @HINT: Determine when a form element is activated by tabbing into it... */
/* @HINT: ...or focusing the cursor into it */
/* @CHECK: https://w3c.github.io/pointerevents/ */
if (!window.PointerEvent) {
/* @CHECK: http://help.dottoro.com/ljqrtxvj.php */
if (('onactivate' in element)) {
element.addEventListener('activate', onActivate, false)
} else {
element.addEventListener('DOMActivate', onActivate, false);
}
} else {
element.addEventListener('focus', onChange, false);
}
/* @HINT: If the form element can have a `onchange` event handler attached to it ... */
if ('onchange' in element) {
/* @HINT: Then... attach the event handler(s) */
/* @CHECK: https://developer.mozilla.org/en-US/docs/Web/API/HTMLInputElement/invalid_event */
element.addEventListener('invalid', onInvalid, false);
element.addEventListener('change', onChange, false);
}
}
})
}
/* @CHECK: https://web.dev/bfcache/#never-use-the-unload-event */
/* @HINT: We have attached the event handler but will remove the event handler later so the bfcache still works */
window.addEventListener('beforeunload', onDocChange, false);
document.addEventListener('beforeurlchange', (ev) => {
/* @HINT: If the currently user-focused form is not null and is not filled out... */
if (document.currentlyFocusedForm !== null) {
const formName = document.currentlyFocusedForm.id || document.currentlyFocusedForm.name;
if (!document.currentlyFocusedForm.isFilledOut) {
/* @HINT: Then... create an entry in storage to tag the form as abandoned */
if (document.currentlyFocusedForm.fillHistory.length !== 0) {
const elements = window.Array.from(document.currentlyFocusedForm.elements).filter((element) => {
return element.tagName !== 'BUTTON' && (element.type !== 'hidden' || element.type !== 'reset' || element.type !== 'submit' || element.type !== 'image');
});
const now = Date.now();
const formProgress = window.Math.floor(
document.currentlyFocusedForm.fillHistory.length / elements.length
) * 100;
/* @HINT: If the form wasn't completed a.k.a the form was abandoned! */
if (formProgress < 100) {
/* @HINT: Then... track the details of the currently user-focused form into web (local) `Storage` as abandoned! */
window.localStorage.setItem(
'__form_abandoned_:' + formName,
JSON.stringify({
timestamp: now,
id_or_name: formName,
page_uri: document.currentlyFocusedForm.baseURI || window.location.href,
enctype: document.currentlyFocusedForm.enctype,
action: document.currentlyFocusedForm.action || "",
method: document.currentlyFocusedForm.method,
progress: formProgress + '%'
})
);
if (!ev.detail || !ev.detail.notfromnav) {
/* @HINT: empty out the fill history after all said and done */
document.currentlyFocusedForm.fillHistory.length = 0;
}
}
}
} else {
Object.keys(
window.localStorage
).filter((key) => {
return key.startsWith('__form_abandoned_') && key.endsWith(formName)
}).forEach((key) => {
window.localStorage.removeItem(key);
});
document.currentlyFocusedForm.fillHistory.length = 0;
}
}
}, false);
/* @HINT: Create user session data about HTML forms that are processed for tracking */
if (!window.sessionStorage['forms_processed_for_tracking']) {
window.sessionStorage.setItem('forms_processed_for_tracking', '[]')
}
/* @HINT: Create user session data about HTML forms that have been submitted and... */
/* @HINT: ...therefore need nor require being tracked for form abandonment */
if (!window.sessionStorage['forms_submitted_bypass_tracking']) {
window.sessionStorage.setItem('forms_submitted_bypass_tracking', '[]')
}
/* @HINT: If statement for feature detection */
/* @CHECK: https://developer.mozilla.org/en-US/docs/Learn/Tools_and_testing/Cross_browser_testing/Feature_detection */
if (typeof window.History === 'function') {
/* @HINT: Copy out the native browser `pushState` function for later use */
const __pushState = window.History.prototype.pushState;
/* @HINT: Also, monkey-patch pushState (which is used by React / Vue / jQuery / Vanilla for their routing) */
window.History.prototype.pushState = function (...args) {
const [, , url] = args
/* @HINT: Get the web page origin (protocol + host) */
const origin = window.location.origin
/* @HINT: The URL of the last loaded page */
const newURL = ((url.indexOf('http') === 0 ? url : origin + url) || '').toString()
/* @HINT: The URL of the currently loaded page */
const oldURL = document.URL
/* @HINT: Ensuring that it wasn't a reload but a page transistion to a different page */
const isProperNav = oldURL !== newURL
if (isProperNav) {
/* @HINT: Dispatch a custom event named `beforeurlchange` (Reads as: before URL change) */
document.dispatchEvent(
new Event('beforeurlchange')
)
}
/* @HINT: Call the native browser `pushState` function so everything works as per usual */
return __pushState.apply(this, args);
}
}
}(window, window.document));
@isocroft
Copy link
Author

isocroft commented Feb 1, 2023

/* @USAGE: How to use the above script */

// Assume the above form abandonment script is loaded using a <script> tag
// Assume (also) that the Google Analytics script has been installed in the <head> tag using a <script> tag

const userMustFillAllForms = true;

window.addEventListener('promptuseronforms', (e) => {
   if (userMustFillAllForms) {
      setTimeout(() => {
         window.alert("You cannot fill forms halfway and abandon them!");
      }, 0);
      /* @HINT: Display dialog to user stating unsaved items */
      e.preventDefault();
      // Any form abandonment tracking data will be sent to Google Analytics using the event name: '__form_abandonment'
   }
});

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment