From 56b792c014125dc754a6d0b8cf47d3f4016be1d9 Mon Sep 17 00:00:00 2001 From: Tim Yiu <137842098+tyiuhc@users.noreply.github.com> Date: Wed, 17 Dec 2025 17:28:28 -0800 Subject: [PATCH 1/3] feat: add scolled_to trigger --- packages/experiment-tag/src/message-bus.ts | 4 + packages/experiment-tag/src/subscriptions.ts | 84 ++++++++++++++++++++ packages/experiment-tag/src/types.ts | 16 ++++ 3 files changed, 104 insertions(+) diff --git a/packages/experiment-tag/src/message-bus.ts b/packages/experiment-tag/src/message-bus.ts index 14d27fd7..e174d9b3 100644 --- a/packages/experiment-tag/src/message-bus.ts +++ b/packages/experiment-tag/src/message-bus.ts @@ -17,6 +17,9 @@ export type ElementVisiblePayload = { mutationList: MutationRecord[] }; export type AnalyticsEventPayload = AnalyticsEvent; export type ManualTriggerPayload = { name: string }; export type UrlChangePayload = { updateActivePages?: boolean }; +export type ScrolledToPayload = { + scrollPercentage: number; +}; export type MessagePayloads = { element_appeared: ElementAppearedPayload; @@ -24,6 +27,7 @@ export type MessagePayloads = { url_change: UrlChangePayload; analytics_event: AnalyticsEventPayload; manual: ManualTriggerPayload; + scrolled_to: ScrolledToPayload; }; export type MessageType = keyof MessagePayloads; diff --git a/packages/experiment-tag/src/subscriptions.ts b/packages/experiment-tag/src/subscriptions.ts index c62c3b9b..32f34d85 100644 --- a/packages/experiment-tag/src/subscriptions.ts +++ b/packages/experiment-tag/src/subscriptions.ts @@ -7,6 +7,7 @@ import { ElementAppearedPayload, ManualTriggerPayload, MessageType, + ScrolledToPayload, } from './message-bus'; import { DebouncedMutationManager } from './mutation-manager'; import { @@ -15,6 +16,7 @@ import { ManualTriggerValue, PageObject, PageObjects, + ScrolledToTriggerValue, } from './types'; const evaluationEngine = new EvaluationEngine(); @@ -41,6 +43,7 @@ export class SubscriptionManager { private elementVisibilityState: Map = new Map(); private elementAppearedState: Map = new Map(); private activeElementSelectors: Set = new Set(); + private firedScrolledTo: Set = new Set(); constructor( webExperimentClient: DefaultWebExperimentClient, @@ -66,6 +69,7 @@ export class SubscriptionManager { } this.setupMutationObserverPublisher(); this.setupVisibilityPublisher(); + this.setupScrolledToPublisher(); this.setupPageObjectSubscriptions(); this.setupUrlChangeReset(); // Initial check for elements that already exist @@ -181,6 +185,7 @@ export class SubscriptionManager { // Reset element state on URL navigation this.messageBus.subscribe('url_change', () => { this.elementAppearedState.clear(); + this.firedScrolledTo.clear(); this.activeElementSelectors.clear(); const elementSelectors = this.getElementSelectors(); elementSelectors.forEach((selector) => @@ -413,6 +418,70 @@ export class SubscriptionManager { wrapHistoryMethods(); }; + private setupScrolledToPublisher = () => { + const handleScroll = () => { + const scrollPercentage = this.calculateScrollPercentage(); + + // Check each scrolled_to page object + for (const pages of Object.values(this.pageObjects)) { + for (const page of Object.values(pages)) { + if (page.trigger_type === 'scrolled_to') { + const triggerValue = page.trigger_value as ScrolledToTriggerValue; + + if (triggerValue.mode === 'percent') { + const key = `percent:${triggerValue.percentage}`; + if ( + !this.firedScrolledTo.has(key) && + scrollPercentage >= triggerValue.percentage + ) { + this.firedScrolledTo.add(key); + this.messageBus.publish('scrolled_to', { scrollPercentage }); + } + } else if (triggerValue.mode === 'element') { + const element = + this.globalScope.document.querySelector(triggerValue.selector); + if (element) { + const elementPosition = element.getBoundingClientRect().top; + const scrollPosition = this.globalScope.scrollY; + const offset = triggerValue.offsetPx || 0; + const key = `element:${triggerValue.selector}:${offset}`; + + if ( + !this.firedScrolledTo.has(key) && + scrollPosition + this.globalScope.innerHeight >= + elementPosition + scrollPosition + offset + ) { + this.firedScrolledTo.add(key); + this.messageBus.publish('scrolled_to', { scrollPercentage }); + } + } + } + } + } + } + }; + + this.globalScope.addEventListener('scroll', handleScroll, { + passive: true, + }); + + // Check immediately in case user is already scrolled + handleScroll(); + }; + + private calculateScrollPercentage(): number { + const windowHeight = this.globalScope.innerHeight; + const documentHeight = this.globalScope.document.documentElement.scrollHeight; + const scrollTop = this.globalScope.scrollY; + const scrollableHeight = documentHeight - windowHeight; + + if (scrollableHeight <= 0) { + return 100; + } + + return (scrollTop / scrollableHeight) * 100; + } + private isPageObjectActive = ( page: PageObject, message: MessagePayloads[T], @@ -501,6 +570,21 @@ export class SubscriptionManager { return this.elementVisibilityState.get(observerKey) ?? false; } + case 'scrolled_to': { + const triggerValue = page.trigger_value as ScrolledToTriggerValue; + let key: string; + + if (triggerValue.mode === 'percent') { + key = `percent:${triggerValue.percentage}`; + } else { + const offset = triggerValue.offsetPx || 0; + key = `element:${triggerValue.selector}:${offset}`; + } + + // Check if this scroll trigger has already fired + return this.firedScrolledTo.has(key); + } + default: return false; } diff --git a/packages/experiment-tag/src/types.ts b/packages/experiment-tag/src/types.ts index 42639506..d0ffaeb2 100644 --- a/packages/experiment-tag/src/types.ts +++ b/packages/experiment-tag/src/types.ts @@ -45,6 +45,21 @@ export interface ManualTriggerValue { name: string; } +export interface ScrolledToElementConfig { + mode: 'element'; + selector: string; + offsetPx?: number; +} + +export interface ScrolledToPercentConfig { + mode: 'percent'; + percentage: number; +} + +export type ScrolledToTriggerValue = + | ScrolledToElementConfig + | ScrolledToPercentConfig; + export type PageObject = { id: string; name: string; @@ -54,6 +69,7 @@ export type PageObject = { | ElementAppearedTriggerValue | ElementVisibleTriggerValue | ManualTriggerValue + | ScrolledToTriggerValue | Record; }; From 5809c2659e802e93aad4b96a34c3a26fcb6a4d56 Mon Sep 17 00:00:00 2001 From: Tim Yiu <137842098+tyiuhc@users.noreply.github.com> Date: Thu, 18 Dec 2025 16:00:24 -0800 Subject: [PATCH 2/3] simplify trigger checking --- packages/experiment-tag/src/message-bus.ts | 1 + packages/experiment-tag/src/subscriptions.ts | 124 ++++++++++++------- 2 files changed, 78 insertions(+), 47 deletions(-) diff --git a/packages/experiment-tag/src/message-bus.ts b/packages/experiment-tag/src/message-bus.ts index e174d9b3..c9d12646 100644 --- a/packages/experiment-tag/src/message-bus.ts +++ b/packages/experiment-tag/src/message-bus.ts @@ -19,6 +19,7 @@ export type ManualTriggerPayload = { name: string }; export type UrlChangePayload = { updateActivePages?: boolean }; export type ScrolledToPayload = { scrollPercentage: number; + elementAndOffset: Set; // Keys like "selector:offsetPx" for elements in range }; export type MessagePayloads = { diff --git a/packages/experiment-tag/src/subscriptions.ts b/packages/experiment-tag/src/subscriptions.ts index 32f34d85..bf38fc28 100644 --- a/packages/experiment-tag/src/subscriptions.ts +++ b/packages/experiment-tag/src/subscriptions.ts @@ -43,7 +43,6 @@ export class SubscriptionManager { private elementVisibilityState: Map = new Map(); private elementAppearedState: Map = new Map(); private activeElementSelectors: Set = new Set(); - private firedScrolledTo: Set = new Set(); constructor( webExperimentClient: DefaultWebExperimentClient, @@ -185,7 +184,6 @@ export class SubscriptionManager { // Reset element state on URL navigation this.messageBus.subscribe('url_change', () => { this.elementAppearedState.clear(); - this.firedScrolledTo.clear(); this.activeElementSelectors.clear(); const elementSelectors = this.getElementSelectors(); elementSelectors.forEach((selector) => @@ -419,54 +417,84 @@ export class SubscriptionManager { }; private setupScrolledToPublisher = () => { - const handleScroll = () => { - const scrollPercentage = this.calculateScrollPercentage(); + // Collect static list of scroll targets from page objects + const percentageTargets: number[] = []; + const elementTargets: Array<{ selector: string; offsetPx: number }> = []; - // Check each scrolled_to page object - for (const pages of Object.values(this.pageObjects)) { - for (const page of Object.values(pages)) { - if (page.trigger_type === 'scrolled_to') { - const triggerValue = page.trigger_value as ScrolledToTriggerValue; - - if (triggerValue.mode === 'percent') { - const key = `percent:${triggerValue.percentage}`; - if ( - !this.firedScrolledTo.has(key) && - scrollPercentage >= triggerValue.percentage - ) { - this.firedScrolledTo.add(key); - this.messageBus.publish('scrolled_to', { scrollPercentage }); - } - } else if (triggerValue.mode === 'element') { - const element = - this.globalScope.document.querySelector(triggerValue.selector); - if (element) { - const elementPosition = element.getBoundingClientRect().top; - const scrollPosition = this.globalScope.scrollY; - const offset = triggerValue.offsetPx || 0; - const key = `element:${triggerValue.selector}:${offset}`; - - if ( - !this.firedScrolledTo.has(key) && - scrollPosition + this.globalScope.innerHeight >= - elementPosition + scrollPosition + offset - ) { - this.firedScrolledTo.add(key); - this.messageBus.publish('scrolled_to', { scrollPercentage }); - } - } + for (const pages of Object.values(this.pageObjects)) { + for (const page of Object.values(pages)) { + if (page.trigger_type === 'scrolled_to') { + const triggerValue = page.trigger_value as ScrolledToTriggerValue; + + if (triggerValue.mode === 'percent') { + if (!percentageTargets.includes(triggerValue.percentage)) { + percentageTargets.push(triggerValue.percentage); } + } else if (triggerValue.mode === 'element') { + const offset = triggerValue.offsetPx || 0; + // Add if not already present + if ( + !elementTargets.some( + (t) => t.selector === triggerValue.selector && t.offsetPx === offset, + ) + ) { + elementTargets.push({ selector: triggerValue.selector, offsetPx: offset }); + } + } + } + } + } + + // Skip setup if no scroll triggers + if (percentageTargets.length === 0 && elementTargets.length === 0) { + return; + } + + const handleScroll = ( + percentages: number[], + elements: Array<{ selector: string; offsetPx: number }>, + ) => { + const scrollPercentage = this.calculateScrollPercentage(); + const elementAndOffset = new Set(); + + // Check which elements are in range + for (const { selector, offsetPx } of elements) { + const element = this.globalScope.document.querySelector(selector); + if (element) { + const elementPosition = element.getBoundingClientRect().top; + const scrollPosition = this.globalScope.scrollY; + + if ( + scrollPosition + this.globalScope.innerHeight >= + elementPosition + scrollPosition + offsetPx + ) { + const key = `${selector}:${offsetPx}`; + elementAndOffset.add(key); } } } + + // Publish if any percentage or element target is met + const shouldPublish = + percentages.some((p) => scrollPercentage >= p) || + elementAndOffset.size > 0; + + if (shouldPublish) { + this.messageBus.publish('scrolled_to', { + scrollPercentage, + elementAndOffset, + }); + } }; - this.globalScope.addEventListener('scroll', handleScroll, { - passive: true, - }); + this.globalScope.addEventListener( + 'scroll', + () => handleScroll(percentageTargets, elementTargets), + { passive: true }, + ); // Check immediately in case user is already scrolled - handleScroll(); + handleScroll(percentageTargets, elementTargets); }; private calculateScrollPercentage(): number { @@ -572,17 +600,19 @@ export class SubscriptionManager { case 'scrolled_to': { const triggerValue = page.trigger_value as ScrolledToTriggerValue; - let key: string; + const scrollPayload = message as ScrolledToPayload; + const currentScrollPercentage = scrollPayload.scrollPercentage; + const elementAndOffset = scrollPayload.elementAndOffset; if (triggerValue.mode === 'percent') { - key = `percent:${triggerValue.percentage}`; - } else { + return currentScrollPercentage >= triggerValue.percentage; + } else if (triggerValue.mode === 'element') { const offset = triggerValue.offsetPx || 0; - key = `element:${triggerValue.selector}:${offset}`; + const key = `${triggerValue.selector}:${offset}`; + return elementAndOffset.has(key); } - // Check if this scroll trigger has already fired - return this.firedScrolledTo.has(key); + return false; } default: From 4df113331b9392d115c250761e980fbbe43bb253 Mon Sep 17 00:00:00 2001 From: Tim Yiu <137842098+tyiuhc@users.noreply.github.com> Date: Thu, 18 Dec 2025 16:05:50 -0800 Subject: [PATCH 3/3] use min scroll percentage --- packages/experiment-tag/src/subscriptions.ts | 32 ++++++++++++-------- 1 file changed, 19 insertions(+), 13 deletions(-) diff --git a/packages/experiment-tag/src/subscriptions.ts b/packages/experiment-tag/src/subscriptions.ts index bf38fc28..5c805fc0 100644 --- a/packages/experiment-tag/src/subscriptions.ts +++ b/packages/experiment-tag/src/subscriptions.ts @@ -418,7 +418,7 @@ export class SubscriptionManager { private setupScrolledToPublisher = () => { // Collect static list of scroll targets from page objects - const percentageTargets: number[] = []; + let minPercentage: number | undefined = undefined; const elementTargets: Array<{ selector: string; offsetPx: number }> = []; for (const pages of Object.values(this.pageObjects)) { @@ -427,18 +427,23 @@ export class SubscriptionManager { const triggerValue = page.trigger_value as ScrolledToTriggerValue; if (triggerValue.mode === 'percent') { - if (!percentageTargets.includes(triggerValue.percentage)) { - percentageTargets.push(triggerValue.percentage); - } + minPercentage = + minPercentage === undefined + ? triggerValue.percentage + : Math.min(minPercentage, triggerValue.percentage); } else if (triggerValue.mode === 'element') { const offset = triggerValue.offsetPx || 0; // Add if not already present if ( !elementTargets.some( - (t) => t.selector === triggerValue.selector && t.offsetPx === offset, + (t) => + t.selector === triggerValue.selector && t.offsetPx === offset, ) ) { - elementTargets.push({ selector: triggerValue.selector, offsetPx: offset }); + elementTargets.push({ + selector: triggerValue.selector, + offsetPx: offset, + }); } } } @@ -446,12 +451,12 @@ export class SubscriptionManager { } // Skip setup if no scroll triggers - if (percentageTargets.length === 0 && elementTargets.length === 0) { + if (minPercentage === undefined && elementTargets.length === 0) { return; } const handleScroll = ( - percentages: number[], + minPercent: number | undefined, elements: Array<{ selector: string; offsetPx: number }>, ) => { const scrollPercentage = this.calculateScrollPercentage(); @@ -474,9 +479,9 @@ export class SubscriptionManager { } } - // Publish if any percentage or element target is met + // Publish if minimum percentage threshold is met or any element is in range const shouldPublish = - percentages.some((p) => scrollPercentage >= p) || + (minPercent !== undefined && scrollPercentage >= minPercent) || elementAndOffset.size > 0; if (shouldPublish) { @@ -489,17 +494,18 @@ export class SubscriptionManager { this.globalScope.addEventListener( 'scroll', - () => handleScroll(percentageTargets, elementTargets), + () => handleScroll(minPercentage, elementTargets), { passive: true }, ); // Check immediately in case user is already scrolled - handleScroll(percentageTargets, elementTargets); + handleScroll(minPercentage, elementTargets); }; private calculateScrollPercentage(): number { const windowHeight = this.globalScope.innerHeight; - const documentHeight = this.globalScope.document.documentElement.scrollHeight; + const documentHeight = + this.globalScope.document.documentElement.scrollHeight; const scrollTop = this.globalScope.scrollY; const scrollableHeight = documentHeight - windowHeight;