From d88eb9957f92a21e8a131cc87a33a1ad36796b69 Mon Sep 17 00:00:00 2001 From: Jethro Nederhof Date: Fri, 17 Oct 2025 11:29:11 +1100 Subject: [PATCH 1/2] Allow opt-in bubble-phase form tracking --- ...R-1776-form-bubbling_2025-10-17-01-21.json | 10 +++++++ ...R-1776-form-bubbling_2025-10-17-01-21.json | 10 +++++++ .../src/helpers.ts | 27 ++++++++++++------- .../test/pages/form-tracking.html | 14 +++++----- 4 files changed, 46 insertions(+), 15 deletions(-) create mode 100644 common/changes/@snowplow/browser-plugin-form-tracking/fix-CSTMR-1776-form-bubbling_2025-10-17-01-21.json create mode 100644 common/changes/@snowplow/javascript-tracker/fix-CSTMR-1776-form-bubbling_2025-10-17-01-21.json 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', { From 1f7a319b032fc7ed014525c28c46a5910d33472c Mon Sep 17 00:00:00 2001 From: Jethro Nederhof Date: Fri, 10 Oct 2025 14:51:51 +1100 Subject: [PATCH 2/2] Fix documentation build properly Not sure what part of the escaping is failing here, so removing all the ambiguity. --- .../react-native-tracker.getwebviewcallback.md | 2 +- .../markdown/react-native-tracker.md | 2 +- .../fix-docs-build_2025-10-17-01-19.json | 10 ++++++++++ .../react-native-tracker/src/web_view_interface.ts | 3 +-- 4 files changed, 13 insertions(+), 4 deletions(-) create mode 100644 common/changes/@snowplow/react-native-tracker/fix-docs-build_2025-10-17-01-19.json diff --git a/api-docs/docs/react-native-tracker/markdown/react-native-tracker.getwebviewcallback.md b/api-docs/docs/react-native-tracker/markdown/react-native-tracker.getwebviewcallback.md index afedd98d4..cf17e1b19 100644 --- a/api-docs/docs/react-native-tracker/markdown/react-native-tracker.getwebviewcallback.md +++ b/api-docs/docs/react-native-tracker/markdown/react-native-tracker.getwebviewcallback.md @@ -6,7 +6,7 @@ Enables tracking events from apps rendered in react-native-webview components. The apps need to use the Snowplow WebView tracker to track the events. -To subscribe for the events, set the `onMessage` attribute: `` +To subscribe for the events, use as the `onMessage` prop for a `WebView` component. Signature: diff --git a/api-docs/docs/react-native-tracker/markdown/react-native-tracker.md b/api-docs/docs/react-native-tracker/markdown/react-native-tracker.md index 5e6f97bf0..22ecb8b29 100644 --- a/api-docs/docs/react-native-tracker/markdown/react-native-tracker.md +++ b/api-docs/docs/react-native-tracker/markdown/react-native-tracker.md @@ -17,7 +17,7 @@ | --- | --- | | [getAllTrackers()](./react-native-tracker.getalltrackers.md) | Retrieves all initialized trackers | | [getTracker(trackerNamespace)](./react-native-tracker.gettracker.md) | Retrieves an initialized tracker given its namespace | -| [getWebViewCallback()](./react-native-tracker.getwebviewcallback.md) | Enables tracking events from apps rendered in react-native-webview components. The apps need to use the Snowplow WebView tracker to track the events.To subscribe for the events, set the onMessage attribute: <WebView onMessage={getWebViewCallback()} ... /> | +| [getWebViewCallback()](./react-native-tracker.getwebviewcallback.md) | Enables tracking events from apps rendered in react-native-webview components. The apps need to use the Snowplow WebView tracker to track the events.To subscribe for the events, use as the onMessage prop for a WebView component. | | [newTracker(configuration)](./react-native-tracker.newtracker.md) | Creates a new tracker instance with the given configuration | | [removeAllTrackers()](./react-native-tracker.removealltrackers.md) | Removes all initialized trackers | | [removeTracker(trackerNamespace)](./react-native-tracker.removetracker.md) | Removes a tracker given its namespace | diff --git a/common/changes/@snowplow/react-native-tracker/fix-docs-build_2025-10-17-01-19.json b/common/changes/@snowplow/react-native-tracker/fix-docs-build_2025-10-17-01-19.json new file mode 100644 index 000000000..a1602d217 --- /dev/null +++ b/common/changes/@snowplow/react-native-tracker/fix-docs-build_2025-10-17-01-19.json @@ -0,0 +1,10 @@ +{ + "changes": [ + { + "packageName": "@snowplow/react-native-tracker", + "comment": "", + "type": "none" + } + ], + "packageName": "@snowplow/react-native-tracker" +} \ No newline at end of file diff --git a/trackers/react-native-tracker/src/web_view_interface.ts b/trackers/react-native-tracker/src/web_view_interface.ts index f177beab7..d831a76f7 100644 --- a/trackers/react-native-tracker/src/web_view_interface.ts +++ b/trackers/react-native-tracker/src/web_view_interface.ts @@ -111,8 +111,7 @@ function webViewPayloadBuilder(pb: PayloadBuilder): PayloadBuilder { * Enables tracking events from apps rendered in react-native-webview components. * The apps need to use the Snowplow WebView tracker to track the events. * - * To subscribe for the events, set the `onMessage` attribute: - * `` + * To subscribe for the events, use as the `onMessage` prop for a `WebView` component. * * @returns Callback to subscribe for events from Web views tracked using the Snowplow WebView tracker. */