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/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/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/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', {
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.
*/