Last active
May 25, 2025 09:36
-
-
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.
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
;(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)); |
Author
isocroft
commented
Feb 1, 2023
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment