Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions packages/experiment-tag/src/message-bus.ts
Original file line number Diff line number Diff line change
Expand Up @@ -17,13 +17,18 @@ 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;
element_visible: ElementVisiblePayload;
url_change: UrlChangePayload;
analytics_event: AnalyticsEventPayload;
manual: ManualTriggerPayload;
user_interaction: UserInteractionPayload;
};

export type MessageType = keyof MessagePayloads;
Expand Down
149 changes: 147 additions & 2 deletions packages/experiment-tag/src/subscriptions.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,11 +2,12 @@ import { EvaluationEngine } from '@amplitude/experiment-core';

import { DefaultWebExperimentClient, INJECT_ACTION } from './experiment';
import {
MessageBus,
MessagePayloads,
ElementAppearedPayload,
ManualTriggerPayload,
MessageBus,
MessagePayloads,
MessageType,
UserInteractionPayload,
} from './message-bus';
import { DebouncedMutationManager } from './mutation-manager';
import {
Expand All @@ -15,6 +16,7 @@ import {
ManualTriggerValue,
PageObject,
PageObjects,
UserInteractionTriggerValue,
} from './types';

const evaluationEngine = new EvaluationEngine();
Expand All @@ -41,6 +43,11 @@ export class SubscriptionManager {
private elementVisibilityState: Map<string, boolean> = new Map();
private elementAppearedState: Map<string, boolean> = new Map();
private activeElementSelectors: Set<string> = new Set();
private userInteractionListeners: Map<
string,
{ element: Element; handler: EventListener; interactionType: string }[]
> = new Map();
private firedUserInteractions: Set<string> = new Set();

constructor(
webExperimentClient: DefaultWebExperimentClient,
Expand All @@ -66,6 +73,7 @@ export class SubscriptionManager {
}
this.setupMutationObserverPublisher();
this.setupVisibilityPublisher();
this.setupUserInteractionPublisher();
this.setupPageObjectSubscriptions();
this.setupUrlChangeReset();
// Initial check for elements that already exist
Expand Down Expand Up @@ -181,12 +189,14 @@ 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) =>
this.activeElementSelectors.add(selector),
);
this.setupVisibilityPublisher();
this.setupUserInteractionPublisher();
this.checkInitialElements();
});
};
Expand Down Expand Up @@ -413,6 +423,127 @@ export class SubscriptionManager {
wrapHistoryMethods();
};

private setupUserInteractionListenersForSelector = (
selector: string,
interactionType: 'click' | 'hover' | 'focus',
minThresholdMs?: number,
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

minThresholdMs is meant to be used for all interactionTypes and not just for hover.

minThresholdMs for click event can be interpreted in multiple ways (delayed trigger after click, click + linger, long click), so let's put a hold on to it for now .

For focus, it should be handled similarly to how hover is determined.

): boolean => {
try {
const elements = this.globalScope.document.querySelectorAll(selector);
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This would fail if an element matching the selector is added to the DOM later, it won't have listeners attached. I'd suggest istead of attaching listeners to each element, attach one listener to document and check if the event target matches the 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;
}
}
Comment on lines +441 to +459
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This would mean hover won't trigger until mouseleave event is fired.

} 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;
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This always returns true even if elements.length is 0

} catch {
return false;
}
};

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;

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', () => {
Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Duplicate subscribers and DOM listeners are being added. Consider making registration idempotent: keep a registry keyed by selector + interactionType (include minThresholdMs for hover) so listeners attach once, and store unsubscribe handles to prevent accumulating element_appeared subscriptions across URL changes.

🚀 Reply to ask Macroscope to explain or update this suggestion.

👍 Helpful? React to give us feedback.

this.setupUserInteractionListenersForSelector(
selector,
interactionType,
minThresholdMs,
);
});
}
}
}
}
};

private isPageObjectActive = <T extends MessageType>(
page: PageObject,
message: MessagePayloads[T],
Expand Down Expand Up @@ -501,6 +632,20 @@ export class SubscriptionManager {
return this.elementVisibilityState.get(observerKey) ?? false;
}

case 'user_interaction': {
const triggerValue = page.trigger_value as UserInteractionTriggerValue;
// 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:
return false;
}
Expand Down
9 changes: 9 additions & 0 deletions packages/experiment-tag/src/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand All @@ -54,6 +62,7 @@ export type PageObject = {
| ElementAppearedTriggerValue
| ElementVisibleTriggerValue
| ManualTriggerValue
| UserInteractionTriggerValue
| Record<string, unknown>;
};

Expand Down
Loading