From f18e96c712fb9c05d43c65d26ae7d417b0067d48 Mon Sep 17 00:00:00 2001 From: Tim Yiu <137842098+tyiuhc@users.noreply.github.com> Date: Thu, 18 Dec 2025 15:19:45 -0800 Subject: [PATCH 1/2] feat: add exit_intent trigger --- packages/experiment-tag/src/message-bus.ts | 2 + packages/experiment-tag/src/subscriptions.ts | 70 ++++++++++++++++++++ packages/experiment-tag/src/types.ts | 5 ++ 3 files changed, 77 insertions(+) diff --git a/packages/experiment-tag/src/message-bus.ts b/packages/experiment-tag/src/message-bus.ts index 14d27fd7..18a52d2f 100644 --- a/packages/experiment-tag/src/message-bus.ts +++ b/packages/experiment-tag/src/message-bus.ts @@ -17,6 +17,7 @@ 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; @@ -24,6 +25,7 @@ export type MessagePayloads = { url_change: UrlChangePayload; analytics_event: AnalyticsEventPayload; manual: ManualTriggerPayload; + exit_intent: ExitIntentPayload; }; export type MessageType = keyof MessagePayloads; diff --git a/packages/experiment-tag/src/subscriptions.ts b/packages/experiment-tag/src/subscriptions.ts index c62c3b9b..78622798 100644 --- a/packages/experiment-tag/src/subscriptions.ts +++ b/packages/experiment-tag/src/subscriptions.ts @@ -6,6 +6,7 @@ import { MessagePayloads, ElementAppearedPayload, ManualTriggerPayload, + ExitIntentPayload, MessageType, } from './message-bus'; import { DebouncedMutationManager } from './mutation-manager'; @@ -13,6 +14,7 @@ import { ElementAppearedTriggerValue, ElementVisibleTriggerValue, ManualTriggerValue, + ExitIntentTriggerValue, PageObject, PageObjects, } from './types'; @@ -41,6 +43,7 @@ export class SubscriptionManager { private elementVisibilityState: Map = new Map(); private elementAppearedState: Map = new Map(); private activeElementSelectors: Set = new Set(); + private pageLoadTime: number = Date.now(); constructor( webExperimentClient: DefaultWebExperimentClient, @@ -66,6 +69,7 @@ export class SubscriptionManager { } this.setupMutationObserverPublisher(); this.setupVisibilityPublisher(); + this.setupExitIntentPublisher(); this.setupPageObjectSubscriptions(); this.setupUrlChangeReset(); // Initial check for elements that already exist @@ -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), @@ -373,6 +378,62 @@ export class SubscriptionManager { }); }; + private setupExitIntentPublisher = () => { + // Check if any page objects use exit_intent trigger + const hasExitIntentTriggers = Object.values(this.pageObjects).some( + (pages) => + Object.values(pages).some( + (page) => page.trigger_type === 'exit_intent', + ), + ); + + if (!hasExitIntentTriggers) { + return; + } + + // Get minimum time requirement (use lowest value so listener activates earliest) + let minTimeOnPageMs = 0; + for (const pages of Object.values(this.pageObjects)) { + for (const page of Object.values(pages)) { + if (page.trigger_type === 'exit_intent') { + const triggerValue = page.trigger_value as ExitIntentTriggerValue; + if (triggerValue.minTimeOnPageMs !== undefined) { + minTimeOnPageMs = + minTimeOnPageMs === 0 + ? triggerValue.minTimeOnPageMs + : Math.min(minTimeOnPageMs, triggerValue.minTimeOnPageMs); + } + } + } + } + + // Detect exit intent via mouse movement + const handleMouseOut = (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(() => { + this.globalScope.document.addEventListener('mouseout', handleMouseOut); + }, minTimeOnPageMs); + } else { + // Install immediately if no time requirement + this.globalScope.document.addEventListener('mouseout', handleMouseOut); + } + }; + private setupLocationChangePublisher = () => { // Add URL change listener for back/forward navigation this.globalScope.addEventListener('popstate', () => { @@ -501,6 +562,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; } diff --git a/packages/experiment-tag/src/types.ts b/packages/experiment-tag/src/types.ts index 42639506..7a87a772 100644 --- a/packages/experiment-tag/src/types.ts +++ b/packages/experiment-tag/src/types.ts @@ -45,6 +45,10 @@ export interface ManualTriggerValue { name: string; } +export interface ExitIntentTriggerValue { + minTimeOnPageMs?: number; +} + export type PageObject = { id: string; name: string; @@ -54,6 +58,7 @@ export type PageObject = { | ElementAppearedTriggerValue | ElementVisibleTriggerValue | ManualTriggerValue + | ExitIntentTriggerValue | Record; }; From b211504bbe7269ed8cd29fada648b8ec0053b38e Mon Sep 17 00:00:00 2001 From: Tim Yiu <137842098+tyiuhc@users.noreply.github.com> Date: Fri, 19 Dec 2025 16:32:04 -0800 Subject: [PATCH 2/2] simplify getting minTimeOnPage, remove duplicate filter logic --- packages/experiment-tag/src/subscriptions.ts | 43 ++++++++++---------- 1 file changed, 21 insertions(+), 22 deletions(-) diff --git a/packages/experiment-tag/src/subscriptions.ts b/packages/experiment-tag/src/subscriptions.ts index 78622798..193c2d42 100644 --- a/packages/experiment-tag/src/subscriptions.ts +++ b/packages/experiment-tag/src/subscriptions.ts @@ -379,36 +379,29 @@ export class SubscriptionManager { }; private setupExitIntentPublisher = () => { - // Check if any page objects use exit_intent trigger - const hasExitIntentTriggers = Object.values(this.pageObjects).some( - (pages) => - Object.values(pages).some( - (page) => page.trigger_type === 'exit_intent', - ), + // 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 (!hasExitIntentTriggers) { + if (pages.length === 0) { return; } // Get minimum time requirement (use lowest value so listener activates earliest) let minTimeOnPageMs = 0; - for (const pages of Object.values(this.pageObjects)) { - for (const page of Object.values(pages)) { - if (page.trigger_type === 'exit_intent') { - const triggerValue = page.trigger_value as ExitIntentTriggerValue; - if (triggerValue.minTimeOnPageMs !== undefined) { - minTimeOnPageMs = - minTimeOnPageMs === 0 - ? triggerValue.minTimeOnPageMs - : Math.min(minTimeOnPageMs, triggerValue.minTimeOnPageMs); - } - } - } + for (const page of pages) { + const triggerValue = page.trigger_value as ExitIntentTriggerValue; + minTimeOnPageMs = Math.min( + minTimeOnPageMs, + triggerValue.minTimeOnPageMs ?? 0, + ); } // Detect exit intent via mouse movement - const handleMouseOut = (event: MouseEvent) => { + 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) @@ -426,11 +419,17 @@ export class SubscriptionManager { // Install listener after minimum time requirement if (minTimeOnPageMs > 0) { setTimeout(() => { - this.globalScope.document.addEventListener('mouseout', handleMouseOut); + this.globalScope.document.addEventListener( + 'mouseleave', + handleMouseLeave, + ); }, minTimeOnPageMs); } else { // Install immediately if no time requirement - this.globalScope.document.addEventListener('mouseout', handleMouseOut); + this.globalScope.document.addEventListener( + 'mouseleave', + handleMouseLeave, + ); } };