From 5616bd33d319960354752d5dde91e3c09a45dae0 Mon Sep 17 00:00:00 2001 From: Tim Yiu <137842098+tyiuhc@users.noreply.github.com> Date: Wed, 17 Dec 2025 16:06:43 -0800 Subject: [PATCH 1/3] feat: add user_interaction trigger --- packages/experiment-tag/src/message-bus.ts | 5 + packages/experiment-tag/src/subscriptions.ts | 121 +++++++++++++++++++ packages/experiment-tag/src/types.ts | 9 ++ 3 files changed, 135 insertions(+) diff --git a/packages/experiment-tag/src/message-bus.ts b/packages/experiment-tag/src/message-bus.ts index 14d27fd7..c9c073e4 100644 --- a/packages/experiment-tag/src/message-bus.ts +++ b/packages/experiment-tag/src/message-bus.ts @@ -17,6 +17,10 @@ export type ElementVisiblePayload = { mutationList: MutationRecord[] }; export type AnalyticsEventPayload = AnalyticsEvent; export type ManualTriggerPayload = { name: string }; export type UrlChangePayload = { updateActivePages?: boolean }; +export type UserInteractionPayload = { + selector: string; + interactionType: 'click' | 'hover' | 'focus'; +}; export type MessagePayloads = { element_appeared: ElementAppearedPayload; @@ -24,6 +28,7 @@ export type MessagePayloads = { url_change: UrlChangePayload; analytics_event: AnalyticsEventPayload; manual: ManualTriggerPayload; + user_interaction: UserInteractionPayload; }; export type MessageType = keyof MessagePayloads; diff --git a/packages/experiment-tag/src/subscriptions.ts b/packages/experiment-tag/src/subscriptions.ts index a1d69f84..352698ac 100644 --- a/packages/experiment-tag/src/subscriptions.ts +++ b/packages/experiment-tag/src/subscriptions.ts @@ -7,6 +7,7 @@ import { ElementAppearedPayload, ManualTriggerPayload, MessageType, + UserInteractionPayload, } from './message-bus'; import { DebouncedMutationManager } from './mutation-manager'; import { @@ -15,6 +16,7 @@ import { ManualTriggerValue, PageObject, PageObjects, + UserInteractionTriggerValue, } from './types'; const evaluationEngine = new EvaluationEngine(); @@ -41,6 +43,10 @@ export class SubscriptionManager { private elementVisibilityState: Map = new Map(); private elementAppearedState: Map = new Map(); private activeElementSelectors: Set = new Set(); + private userInteractionListeners: Map< + string, + { element: Element; handler: EventListener; interactionType: string }[] + > = new Map(); constructor( webExperimentClient: DefaultWebExperimentClient, @@ -66,6 +72,7 @@ export class SubscriptionManager { } this.setupMutationObserverPublisher(); this.setupVisibilityPublisher(); + this.setupUserInteractionPublisher(); this.setupPageObjectSubscriptions(); this.setupUrlChangeReset(); // Initial check for elements that already exist @@ -186,6 +193,7 @@ export class SubscriptionManager { this.activeElementSelectors.add(selector), ); this.setupVisibilityPublisher(); + this.setupUserInteractionPublisher(); this.checkInitialElements(); }); }; @@ -412,6 +420,110 @@ export class SubscriptionManager { wrapHistoryMethods(); }; + private setupUserInteractionPublisher = () => { + // Clear any existing listeners first + this.userInteractionListeners.forEach((listeners) => { + listeners.forEach(({ element, handler, interactionType }) => { + element.removeEventListener(interactionType, handler); + }); + }); + this.userInteractionListeners.clear(); + + for (const pages of Object.values(this.pageObjects)) { + for (const page of Object.values(pages)) { + if (page.trigger_type === 'user_interaction') { + const triggerValue = + page.trigger_value as UserInteractionTriggerValue; + const { selector, interactionType, minThresholdMs } = triggerValue; + + // Find all elements matching the selector + const elements = this.globalScope.document.querySelectorAll(selector); + + elements.forEach((element) => { + let interactionStartTime: number | null = null; + + const handler = (event: Event) => { + if (interactionType === 'hover') { + // For hover, track mouse enter and leave + if (event.type === 'mouseenter') { + interactionStartTime = Date.now(); + } else if (event.type === 'mouseleave') { + if (interactionStartTime !== null) { + const interactionDuration = + Date.now() - interactionStartTime; + if ( + !minThresholdMs || + interactionDuration >= minThresholdMs + ) { + this.messageBus.publish('user_interaction', { + selector, + interactionType, + }); + } + interactionStartTime = null; + } + } + } else if (interactionType === 'focus') { + // For focus, track focus and blur + if (event.type === 'focus') { + interactionStartTime = Date.now(); + } else if (event.type === 'blur') { + if (interactionStartTime !== null) { + const interactionDuration = + Date.now() - interactionStartTime; + if ( + !minThresholdMs || + interactionDuration >= minThresholdMs + ) { + this.messageBus.publish('user_interaction', { + selector, + interactionType, + }); + } + interactionStartTime = null; + } + } + } else { + this.messageBus.publish('user_interaction', { + selector, + interactionType, + }); + } + }; + + if (interactionType === 'click') { + element.addEventListener('click', handler); + const key = `${selector}:${interactionType}`; + const listeners = this.userInteractionListeners.get(key) || []; + listeners.push({ element, handler, interactionType: 'click' }); + this.userInteractionListeners.set(key, listeners); + } else if (interactionType === 'hover') { + element.addEventListener('mouseenter', handler); + element.addEventListener('mouseleave', handler); + const key = `${selector}:${interactionType}`; + const listeners = this.userInteractionListeners.get(key) || []; + listeners.push( + { element, handler, interactionType: 'mouseenter' }, + { element, handler, interactionType: 'mouseleave' }, + ); + this.userInteractionListeners.set(key, listeners); + } else if (interactionType === 'focus') { + element.addEventListener('focus', handler); + element.addEventListener('blur', handler); + const key = `${selector}:${interactionType}`; + const listeners = this.userInteractionListeners.get(key) || []; + listeners.push( + { element, handler, interactionType: 'focus' }, + { element, handler, interactionType: 'blur' }, + ); + this.userInteractionListeners.set(key, listeners); + } + }); + } + } + } + }; + private isPageObjectActive = ( page: PageObject, message: MessagePayloads[T], @@ -500,6 +612,15 @@ export class SubscriptionManager { return this.elementVisibilityState.get(observerKey) ?? false; } + case 'user_interaction': { + const triggerValue = page.trigger_value as UserInteractionTriggerValue; + const interactionMessage = message as UserInteractionPayload; + return ( + interactionMessage.selector === triggerValue.selector && + interactionMessage.interactionType === triggerValue.interactionType + ); + } + default: return false; } diff --git a/packages/experiment-tag/src/types.ts b/packages/experiment-tag/src/types.ts index 42639506..8360ff66 100644 --- a/packages/experiment-tag/src/types.ts +++ b/packages/experiment-tag/src/types.ts @@ -45,6 +45,14 @@ export interface ManualTriggerValue { name: string; } +export type UserInteractionType = 'click' | 'hover' | 'focus'; + +export interface UserInteractionTriggerValue { + selector: string; + interactionType: UserInteractionType; + minThresholdMs?: number; +} + export type PageObject = { id: string; name: string; @@ -54,6 +62,7 @@ export type PageObject = { | ElementAppearedTriggerValue | ElementVisibleTriggerValue | ManualTriggerValue + | UserInteractionTriggerValue | Record; }; From 5308888487477c99878fda1764ed2b1fe174ecc6 Mon Sep 17 00:00:00 2001 From: Tim Yiu <137842098+tyiuhc@users.noreply.github.com> Date: Wed, 17 Dec 2025 17:03:37 -0800 Subject: [PATCH 2/3] update with state keeping --- packages/experiment-tag/src/subscriptions.ts | 56 ++++++++++---------- 1 file changed, 27 insertions(+), 29 deletions(-) diff --git a/packages/experiment-tag/src/subscriptions.ts b/packages/experiment-tag/src/subscriptions.ts index 352698ac..220a161d 100644 --- a/packages/experiment-tag/src/subscriptions.ts +++ b/packages/experiment-tag/src/subscriptions.ts @@ -2,10 +2,10 @@ import { EvaluationEngine } from '@amplitude/experiment-core'; import { DefaultWebExperimentClient, INJECT_ACTION } from './experiment'; import { - MessageBus, - MessagePayloads, ElementAppearedPayload, ManualTriggerPayload, + MessageBus, + MessagePayloads, MessageType, UserInteractionPayload, } from './message-bus'; @@ -47,6 +47,7 @@ export class SubscriptionManager { string, { element: Element; handler: EventListener; interactionType: string }[] > = new Map(); + private firedUserInteractions: Set = new Set(); constructor( webExperimentClient: DefaultWebExperimentClient, @@ -187,6 +188,7 @@ export class SubscriptionManager { // Reset element state on URL navigation this.messageBus.subscribe('url_change', () => { this.elementAppearedState.clear(); + this.firedUserInteractions.clear(); this.activeElementSelectors.clear(); const elementSelectors = this.getElementSelectors(); elementSelectors.forEach((selector) => @@ -455,6 +457,10 @@ export class SubscriptionManager { !minThresholdMs || interactionDuration >= minThresholdMs ) { + const interactionKey = `${selector}:${interactionType}:${ + minThresholdMs || 0 + }`; + this.firedUserInteractions.add(interactionKey); this.messageBus.publish('user_interaction', { selector, interactionType, @@ -464,26 +470,17 @@ export class SubscriptionManager { } } } else if (interactionType === 'focus') { - // For focus, track focus and blur if (event.type === 'focus') { - interactionStartTime = Date.now(); - } else if (event.type === 'blur') { - if (interactionStartTime !== null) { - const interactionDuration = - Date.now() - interactionStartTime; - if ( - !minThresholdMs || - interactionDuration >= minThresholdMs - ) { - this.messageBus.publish('user_interaction', { - selector, - interactionType, - }); - } - interactionStartTime = null; - } + const interactionKey = `${selector}:${interactionType}`; + this.firedUserInteractions.add(interactionKey); + this.messageBus.publish('user_interaction', { + selector, + interactionType, + }); } } else { + const interactionKey = `${selector}:${interactionType}`; + this.firedUserInteractions.add(interactionKey); this.messageBus.publish('user_interaction', { selector, interactionType, @@ -509,13 +506,9 @@ export class SubscriptionManager { this.userInteractionListeners.set(key, listeners); } else if (interactionType === 'focus') { element.addEventListener('focus', handler); - element.addEventListener('blur', handler); const key = `${selector}:${interactionType}`; const listeners = this.userInteractionListeners.get(key) || []; - listeners.push( - { element, handler, interactionType: 'focus' }, - { element, handler, interactionType: 'blur' }, - ); + listeners.push({ element, handler, interactionType: 'focus' }); this.userInteractionListeners.set(key, listeners); } }); @@ -614,11 +607,16 @@ export class SubscriptionManager { case 'user_interaction': { const triggerValue = page.trigger_value as UserInteractionTriggerValue; - const interactionMessage = message as UserInteractionPayload; - return ( - interactionMessage.selector === triggerValue.selector && - interactionMessage.interactionType === triggerValue.interactionType - ); + // Include minThresholdMs in key for hover to differentiate between different durations + const interactionKey = + triggerValue.interactionType === 'hover' + ? `${triggerValue.selector}:${triggerValue.interactionType}:${ + triggerValue.minThresholdMs || 0 + }` + : `${triggerValue.selector}:${triggerValue.interactionType}`; + + // Check if this interaction has already fired + return this.firedUserInteractions.has(interactionKey); } default: From 2a3cff764d24676e8576cb8dee6d8ae06d6efab9 Mon Sep 17 00:00:00 2001 From: Tim Yiu <137842098+tyiuhc@users.noreply.github.com> Date: Thu, 18 Dec 2025 16:18:32 -0800 Subject: [PATCH 3/3] refactor and wait for element to appear --- packages/experiment-tag/src/subscriptions.ts | 172 +++++++++++-------- 1 file changed, 99 insertions(+), 73 deletions(-) diff --git a/packages/experiment-tag/src/subscriptions.ts b/packages/experiment-tag/src/subscriptions.ts index d5faaf48..6c3b3213 100644 --- a/packages/experiment-tag/src/subscriptions.ts +++ b/packages/experiment-tag/src/subscriptions.ts @@ -423,6 +423,90 @@ export class SubscriptionManager { wrapHistoryMethods(); }; + private setupUserInteractionListenersForSelector = ( + selector: string, + interactionType: 'click' | 'hover' | 'focus', + minThresholdMs?: number, + ): boolean => { + try { + const elements = this.globalScope.document.querySelectorAll(selector); + + elements.forEach((element) => { + let interactionStartTime: number | null = null; + + const handler = (event: Event) => { + if (interactionType === 'hover') { + if (event.type === 'mouseenter') { + interactionStartTime = Date.now(); + } else if (event.type === 'mouseleave') { + if (interactionStartTime !== null) { + const interactionDuration = Date.now() - interactionStartTime; + if ( + !minThresholdMs || + interactionDuration >= minThresholdMs + ) { + const interactionKey = `${selector}:${interactionType}:${ + minThresholdMs || 0 + }`; + this.firedUserInteractions.add(interactionKey); + this.messageBus.publish('user_interaction', { + selector, + interactionType, + }); + } + interactionStartTime = null; + } + } + } else if (interactionType === 'focus') { + if (event.type === 'focus') { + const interactionKey = `${selector}:${interactionType}`; + this.firedUserInteractions.add(interactionKey); + this.messageBus.publish('user_interaction', { + selector, + interactionType, + }); + } + } else { + const interactionKey = `${selector}:${interactionType}`; + this.firedUserInteractions.add(interactionKey); + this.messageBus.publish('user_interaction', { + selector, + interactionType, + }); + } + }; + + if (interactionType === 'click') { + element.addEventListener('click', handler); + const key = `${selector}:${interactionType}`; + const listeners = this.userInteractionListeners.get(key) || []; + listeners.push({ element, handler, interactionType: 'click' }); + this.userInteractionListeners.set(key, listeners); + } else if (interactionType === 'hover') { + element.addEventListener('mouseenter', handler); + element.addEventListener('mouseleave', handler); + const key = `${selector}:${interactionType}`; + const listeners = this.userInteractionListeners.get(key) || []; + listeners.push( + { element, handler, interactionType: 'mouseenter' }, + { element, handler, interactionType: 'mouseleave' }, + ); + this.userInteractionListeners.set(key, listeners); + } else if (interactionType === 'focus') { + element.addEventListener('focus', handler); + const key = `${selector}:${interactionType}`; + const listeners = this.userInteractionListeners.get(key) || []; + listeners.push({ element, handler, interactionType: 'focus' }); + this.userInteractionListeners.set(key, listeners); + } + }); + + return true; + } catch { + return false; + } + }; + private setupUserInteractionPublisher = () => { // Clear any existing listeners first this.userInteractionListeners.forEach((listeners) => { @@ -439,80 +523,22 @@ export class SubscriptionManager { page.trigger_value as UserInteractionTriggerValue; const { selector, interactionType, minThresholdMs } = triggerValue; - // Find all elements matching the selector - const elements = this.globalScope.document.querySelectorAll(selector); - - elements.forEach((element) => { - let interactionStartTime: number | null = null; - - const handler = (event: Event) => { - if (interactionType === 'hover') { - // For hover, track mouse enter and leave - if (event.type === 'mouseenter') { - interactionStartTime = Date.now(); - } else if (event.type === 'mouseleave') { - if (interactionStartTime !== null) { - const interactionDuration = - Date.now() - interactionStartTime; - if ( - !minThresholdMs || - interactionDuration >= minThresholdMs - ) { - const interactionKey = `${selector}:${interactionType}:${ - minThresholdMs || 0 - }`; - this.firedUserInteractions.add(interactionKey); - this.messageBus.publish('user_interaction', { - selector, - interactionType, - }); - } - interactionStartTime = null; - } - } - } else if (interactionType === 'focus') { - if (event.type === 'focus') { - const interactionKey = `${selector}:${interactionType}`; - this.firedUserInteractions.add(interactionKey); - this.messageBus.publish('user_interaction', { - selector, - interactionType, - }); - } - } else { - const interactionKey = `${selector}:${interactionType}`; - this.firedUserInteractions.add(interactionKey); - this.messageBus.publish('user_interaction', { - selector, - interactionType, - }); - } - }; - - if (interactionType === 'click') { - element.addEventListener('click', handler); - const key = `${selector}:${interactionType}`; - const listeners = this.userInteractionListeners.get(key) || []; - listeners.push({ element, handler, interactionType: 'click' }); - this.userInteractionListeners.set(key, listeners); - } else if (interactionType === 'hover') { - element.addEventListener('mouseenter', handler); - element.addEventListener('mouseleave', handler); - const key = `${selector}:${interactionType}`; - const listeners = this.userInteractionListeners.get(key) || []; - listeners.push( - { element, handler, interactionType: 'mouseenter' }, - { element, handler, interactionType: 'mouseleave' }, + const success = this.setupUserInteractionListenersForSelector( + selector, + interactionType, + minThresholdMs, + ); + + if (!success) { + // Invalid selector or elements don't exist yet - wait for element_appeared + this.messageBus.subscribe('element_appeared', () => { + this.setupUserInteractionListenersForSelector( + selector, + interactionType, + minThresholdMs, ); - this.userInteractionListeners.set(key, listeners); - } else if (interactionType === 'focus') { - element.addEventListener('focus', handler); - const key = `${selector}:${interactionType}`; - const listeners = this.userInteractionListeners.get(key) || []; - listeners.push({ element, handler, interactionType: 'focus' }); - this.userInteractionListeners.set(key, listeners); - } - }); + }); + } } } }