diff --git a/common/changes/@snowplow/browser-plugin-form-tracking/fix-CSTMR-1776-form-bubbling_2025-10-17-01-21.json b/common/changes/@snowplow/browser-plugin-form-tracking/fix-CSTMR-1776-form-bubbling_2025-10-17-01-21.json new file mode 100644 index 000000000..fcd9f9cbd --- /dev/null +++ b/common/changes/@snowplow/browser-plugin-form-tracking/fix-CSTMR-1776-form-bubbling_2025-10-17-01-21.json @@ -0,0 +1,10 @@ +{ + "changes": [ + { + "packageName": "@snowplow/browser-plugin-form-tracking", + "comment": "Allow opt-in bubble-phase listeners for change/submit", + "type": "none" + } + ], + "packageName": "@snowplow/browser-plugin-form-tracking" +} \ No newline at end of file diff --git a/common/changes/@snowplow/javascript-tracker/fix-CSTMR-1776-form-bubbling_2025-10-17-01-21.json b/common/changes/@snowplow/javascript-tracker/fix-CSTMR-1776-form-bubbling_2025-10-17-01-21.json new file mode 100644 index 000000000..ae2f53795 --- /dev/null +++ b/common/changes/@snowplow/javascript-tracker/fix-CSTMR-1776-form-bubbling_2025-10-17-01-21.json @@ -0,0 +1,10 @@ +{ + "changes": [ + { + "packageName": "@snowplow/javascript-tracker", + "comment": "", + "type": "none" + } + ], + "packageName": "@snowplow/javascript-tracker" +} \ No newline at end of file diff --git a/plugins/browser-plugin-form-tracking/src/helpers.ts b/plugins/browser-plugin-form-tracking/src/helpers.ts index d7d0e7309..6153fee67 100644 --- a/plugins/browser-plugin-form-tracking/src/helpers.ts +++ b/plugins/browser-plugin-form-tracking/src/helpers.ts @@ -40,6 +40,8 @@ const defaultFormTrackingEvents = [ /** Form tracking plugin options to determine which events to fire and the elements to listen for */ interface FormTrackingOptions { + /** Whether to handle events in the capture phase or the bubbling phase. Capture is usually more reliable, but may trigger early if you need changes from other submit handlers in your transforms, filters, or context generators. Defaults to true. */ + useCapture?: boolean; /** List of `form` elements that are allowed to generate events, or criteria for deciding that when the event listener handles the event */ forms?: | FilterCriterion @@ -86,6 +88,7 @@ const _focusListeners: Record = {}; const _changeListeners: Record = {}; const _submitListeners: Record = {}; const _targets: Record = {}; +const _captures: Record = {}; /** * Add submission/focus/change event listeners to page for forms and elements according to `configuration` @@ -99,19 +102,21 @@ export function addFormListeners(tracker: BrowserTracker, configuration: FormTra const events = options?.events ?? defaultFormTrackingEvents; + const useCapture = (_captures[tracker.id] = options?.useCapture ?? true); + const targets = (_targets[tracker.id] = getTargetList(options?.targets, config.forms)); if (events.indexOf(FormTrackingEvent.FOCUS_FORM) !== -1) { _focusListeners[tracker.id] = getFormChangeListener(tracker, config, FormTrackingEvent.FOCUS_FORM, context); - targets.forEach((target) => addEventListener(target, 'focus', _focusListeners[tracker.id], true)); + targets.forEach((target) => addEventListener(target, 'focus', _focusListeners[tracker.id], true)); // focus does not bubble } if (events.indexOf(FormTrackingEvent.CHANGE_FORM) !== -1) { _changeListeners[tracker.id] = getFormChangeListener(tracker, config, FormTrackingEvent.CHANGE_FORM, context); - targets.forEach((target) => addEventListener(target, 'change', _changeListeners[tracker.id], true)); + targets.forEach((target) => addEventListener(target, 'change', _changeListeners[tracker.id], useCapture)); } if (events.indexOf(FormTrackingEvent.SUBMIT_FORM) !== -1) { _submitListeners[tracker.id] = getFormSubmissionListener(tracker, config, context); - targets.forEach((target) => addEventListener(target, 'submit', _submitListeners[tracker.id], true)); + targets.forEach((target) => addEventListener(target, 'submit', _submitListeners[tracker.id], useCapture)); } } @@ -145,10 +150,11 @@ function getTargetList(configTargets: EventTarget[] | undefined, forms: FormConf */ export function removeFormListeners(tracker: BrowserTracker) { const targets = _targets[tracker.id] ?? [document]; + const useCapture = _captures[tracker.id] ?? true; targets.forEach((target) => { - if (_focusListeners[tracker.id]) target.removeEventListener('focus', _focusListeners[tracker.id], true); - if (_changeListeners[tracker.id]) target.removeEventListener('change', _changeListeners[tracker.id], true); - if (_submitListeners[tracker.id]) target.removeEventListener('submit', _submitListeners[tracker.id], true); + if (_focusListeners[tracker.id]) target.removeEventListener('focus', _focusListeners[tracker.id], true); // focus does not bubble + if (_changeListeners[tracker.id]) target.removeEventListener('change', _changeListeners[tracker.id], useCapture); + if (_submitListeners[tracker.id]) target.removeEventListener('submit', _submitListeners[tracker.id], useCapture); }); } @@ -363,10 +369,13 @@ function getFormChangeListener( // bind late to the forms/field directly on field focus in this case if (target !== e.target && e.composed && isTrackableElement(target)) { if (target.form) { - if (_changeListeners[tracker.id]) addEventListener(target.form, 'change', _changeListeners[tracker.id], true); - if (_submitListeners[tracker.id]) addEventListener(target.form, 'submit', _submitListeners[tracker.id], true); + if (_changeListeners[tracker.id]) + addEventListener(target.form, 'change', _changeListeners[tracker.id], _captures[tracker.id]); + if (_submitListeners[tracker.id]) + addEventListener(target.form, 'submit', _submitListeners[tracker.id], _captures[tracker.id]); } else { - if (_changeListeners[tracker.id]) addEventListener(target, 'change', _changeListeners[tracker.id], true); + if (_changeListeners[tracker.id]) + addEventListener(target, 'change', _changeListeners[tracker.id], _captures[tracker.id]); } } diff --git a/trackers/javascript-tracker/test/pages/form-tracking.html b/trackers/javascript-tracker/test/pages/form-tracking.html index 728b8ceb0..1404f5e4c 100644 --- a/trackers/javascript-tracker/test/pages/form-tracking.html +++ b/trackers/javascript-tracker/test/pages/form-tracking.html @@ -144,14 +144,15 @@ switch (parseQuery().filter) { case 'exclude': - snowplow('enableFormTracking', { options: { fields: { denylist: ['fname'] } } }); + snowplow('enableFormTracking', { options: { useCapture: true, fields: { denylist: ['fname'] } } }); break; case 'include': - snowplow('enableFormTracking', { options: { fields: { allowlist: ['lname'] } } }); + snowplow('enableFormTracking', { options: { useCapture: false, fields: { allowlist: ['lname'] } } }); break; case 'filter': snowplow('enableFormTracking', { options: { + useCapture: undefined, forms: { allowlist: ['formy-mcformface'] }, fields: { filter: formFilter }, }, @@ -160,22 +161,23 @@ case 'transform': snowplow('enableFormTracking', { options: { + useCapture: true, fields: { transform: redactPII }, }, }); break; case 'excludedForm': - snowplow('enableFormTracking', { options: { forms: { denylist: ['excluded-form'] } } }); + snowplow('enableFormTracking', { options: { useCapture: false, forms: { denylist: ['excluded-form'] } } }); break; case 'onlyFocus': - snowplow('enableFormTracking', { options: { events: ['focus_form'] } }); + snowplow('enableFormTracking', { options: { useCapture: undefined, events: ['focus_form'] } }); break; case 'iframeForm': var forms = iframe.contentWindow.document.getElementsByTagName('form'); - snowplow('enableFormTracking', { options: { forms: forms } }); + snowplow('enableFormTracking', { options: { useCapture: true, forms: forms } }); break; case 'shadow': - snowplow('enableFormTracking', { options: { forms: { allowlist: ['shadow-form'] } } }); + snowplow('enableFormTracking', { options: { useCapture: false, forms: { allowlist: ['shadow-form'] } } }); break; default: snowplow('enableFormTracking', {