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
2 changes: 2 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,15 @@ export type ElementVisiblePayload = { mutationList: MutationRecord[] };
export type AnalyticsEventPayload = AnalyticsEvent;
export type ManualTriggerPayload = { name: string };
export type UrlChangePayload = { updateActivePages?: boolean };
export type ExitIntentPayload = { durationMs: number };

export type MessagePayloads = {
element_appeared: ElementAppearedPayload;
element_visible: ElementVisiblePayload;
url_change: UrlChangePayload;
analytics_event: AnalyticsEventPayload;
manual: ManualTriggerPayload;
exit_intent: ExitIntentPayload;
};

export type MessageType = keyof MessagePayloads;
Expand Down
69 changes: 69 additions & 0 deletions packages/experiment-tag/src/subscriptions.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,13 +6,15 @@ import {
MessagePayloads,
ElementAppearedPayload,
ManualTriggerPayload,
ExitIntentPayload,
MessageType,
} from './message-bus';
import { DebouncedMutationManager } from './mutation-manager';
import {
ElementAppearedTriggerValue,
ElementVisibleTriggerValue,
ManualTriggerValue,
ExitIntentTriggerValue,
PageObject,
PageObjects,
} from './types';
Expand Down Expand Up @@ -41,6 +43,7 @@ export class SubscriptionManager {
private elementVisibilityState: Map<string, boolean> = new Map();
private elementAppearedState: Map<string, boolean> = new Map();
private activeElementSelectors: Set<string> = new Set();
private pageLoadTime: number = Date.now();

constructor(
webExperimentClient: DefaultWebExperimentClient,
Expand All @@ -66,6 +69,7 @@ export class SubscriptionManager {
}
this.setupMutationObserverPublisher();
this.setupVisibilityPublisher();
this.setupExitIntentPublisher();
this.setupPageObjectSubscriptions();
this.setupUrlChangeReset();
// Initial check for elements that already exist
Expand Down Expand Up @@ -182,6 +186,7 @@ export class SubscriptionManager {
this.messageBus.subscribe('url_change', () => {
this.elementAppearedState.clear();
this.activeElementSelectors.clear();
this.pageLoadTime = Date.now();
const elementSelectors = this.getElementSelectors();
elementSelectors.forEach((selector) =>
this.activeElementSelectors.add(selector),
Expand Down Expand Up @@ -373,6 +378,61 @@ export class SubscriptionManager {
});
};

private setupExitIntentPublisher = () => {
// Get all page objects that use exit_intent trigger
const pages = Object.values(this.pageObjects).flatMap((pages) =>
Object.values(pages).filter(
(page) => page.trigger_type === 'exit_intent',
),
);

if (pages.length === 0) {
return;
}

// Get minimum time requirement (use lowest value so listener activates earliest)
let minTimeOnPageMs = 0;
for (const page of pages) {
const triggerValue = page.trigger_value as ExitIntentTriggerValue;
minTimeOnPageMs = Math.min(
minTimeOnPageMs,
triggerValue.minTimeOnPageMs ?? 0,
);
}
Comment on lines +394 to +401
Copy link

Choose a reason for hiding this comment

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

minTimeOnPageMs always resolves to 0 because it starts at 0 and uses Math.min(...). Consider initializing to Infinity and defaulting to 0 if none are set so configured delays take effect.

-    let minTimeOnPageMs = 0;
+    let minTimeOnPageMs = Infinity;
     for (const page of pages) {
       const triggerValue = page.trigger_value as ExitIntentTriggerValue;
       minTimeOnPageMs = Math.min(
         minTimeOnPageMs,
         triggerValue.minTimeOnPageMs ?? 0,
       );
     }
+    if (minTimeOnPageMs === Infinity) { minTimeOnPageMs = 0; }

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

👍 Helpful? React to give us feedback.


// Detect exit intent via mouse movement
const handleMouseLeave = (event: MouseEvent) => {
// Only trigger if:
// 1. Mouse Y position is near top of viewport (leaving towards browser chrome)
// 2. Mouse is leaving the document (relatedTarget is null)
// 3. Not already triggered
if (
event.clientY <= 50 && // 50px from top
event.relatedTarget === null
) {
this.messageBus.publish('exit_intent', {
durationMs: Date.now() - this.pageLoadTime,
});
}
};

// Install listener after minimum time requirement
if (minTimeOnPageMs > 0) {
setTimeout(() => {
Copy link

Choose a reason for hiding this comment

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

setupExitIntentPublisher leaks state: re-init can leave a stale setTimeout, add duplicate mouseout handlers, and allow exit_intent to fire multiple times. Suggest making it one-shot by tracking a single handler and timeout on the instance, clearing/removing them on re-init and after first publish, and nulling refs to prevent duplicates.

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

👍 Helpful? React to give us feedback.

Copy link
Collaborator

Choose a reason for hiding this comment

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

These timeouts also need to be cancelled when user navigates to another page etc. It looks like you can already use addPageChangeSubscriber() for this or we could use a pattern of returning and/or collecting cleanup functions.

Copy link
Collaborator Author

Choose a reason for hiding this comment

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

Noted - I will add this in a follow up PR so we can centralize cleanup logic across all triggers

this.globalScope.document.addEventListener(
'mouseleave',
handleMouseLeave,
);
}, minTimeOnPageMs);
} else {
// Install immediately if no time requirement
this.globalScope.document.addEventListener(
'mouseleave',
handleMouseLeave,
);
}
};

private setupLocationChangePublisher = () => {
// Add URL change listener for back/forward navigation
this.globalScope.addEventListener('popstate', () => {
Expand Down Expand Up @@ -501,6 +561,15 @@ export class SubscriptionManager {
return this.elementVisibilityState.get(observerKey) ?? false;
}

case 'exit_intent': {
const durationMs = (message as ExitIntentPayload).durationMs;
const triggerValue = page.trigger_value as ExitIntentTriggerValue;
return (
triggerValue.minTimeOnPageMs === undefined ||
durationMs >= triggerValue.minTimeOnPageMs
);
}

default:
return false;
}
Expand Down
5 changes: 5 additions & 0 deletions packages/experiment-tag/src/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -45,6 +45,10 @@ export interface ManualTriggerValue {
name: string;
}

export interface ExitIntentTriggerValue {
minTimeOnPageMs?: number;
}

export type PageObject = {
id: string;
name: string;
Expand All @@ -54,6 +58,7 @@ export type PageObject = {
| ElementAppearedTriggerValue
| ElementVisibleTriggerValue
| ManualTriggerValue
| ExitIntentTriggerValue
| Record<string, unknown>;
};

Expand Down