From 39ae94e5fc93c98b39f275eb3e0442698d302e7d Mon Sep 17 00:00:00 2001 From: Tim Yiu <137842098+tyiuhc@users.noreply.github.com> Date: Tue, 16 Dec 2025 15:50:17 -0800 Subject: [PATCH 1/2] feat: support new page triggers - element appeared, element visible, and manual --- packages/experiment-tag/src/experiment.ts | 8 + packages/experiment-tag/src/message-bus.ts | 2 + .../experiment-tag/src/mutation-manager.ts | 2 +- packages/experiment-tag/src/subscriptions.ts | 281 ++++++++++++++++-- packages/experiment-tag/src/types.ts | 22 +- .../experiment-tag/test/experiment.test.ts | 45 ++- 6 files changed, 334 insertions(+), 26 deletions(-) diff --git a/packages/experiment-tag/src/experiment.ts b/packages/experiment-tag/src/experiment.ts index 834376bb..c78c51b9 100644 --- a/packages/experiment-tag/src/experiment.ts +++ b/packages/experiment-tag/src/experiment.ts @@ -530,6 +530,14 @@ export class DefaultWebExperimentClient implements WebExperimentClient { this.customRedirectHandler = handler; } + /** + * Manually activate a page trigger with the specified name. + * @param name The name of the manual trigger to activate + */ + public activate(name: string) { + this.messageBus.publish('manual', { name }); + } + private async fetchRemoteFlags() { try { // eslint-disable-next-line @typescript-eslint/ban-ts-comment diff --git a/packages/experiment-tag/src/message-bus.ts b/packages/experiment-tag/src/message-bus.ts index d0054a4a..14d27fd7 100644 --- a/packages/experiment-tag/src/message-bus.ts +++ b/packages/experiment-tag/src/message-bus.ts @@ -13,12 +13,14 @@ type Subscriber = { }; export type ElementAppearedPayload = { mutationList: MutationRecord[] }; +export type ElementVisiblePayload = { mutationList: MutationRecord[] }; export type AnalyticsEventPayload = AnalyticsEvent; export type ManualTriggerPayload = { name: string }; export type UrlChangePayload = { updateActivePages?: boolean }; export type MessagePayloads = { element_appeared: ElementAppearedPayload; + element_visible: ElementVisiblePayload; url_change: UrlChangePayload; analytics_event: AnalyticsEventPayload; manual: ManualTriggerPayload; diff --git a/packages/experiment-tag/src/mutation-manager.ts b/packages/experiment-tag/src/mutation-manager.ts index e70240be..4c6f1b4f 100644 --- a/packages/experiment-tag/src/mutation-manager.ts +++ b/packages/experiment-tag/src/mutation-manager.ts @@ -60,7 +60,7 @@ const DEFAULT_OPTIONS = { attributes: true, attributeFilter: ['style', 'class'], }, - debounceMs: 150, + debounceMs: 100, } satisfies DebouncedMutationManagerOptions; export class DebouncedMutationManager { diff --git a/packages/experiment-tag/src/subscriptions.ts b/packages/experiment-tag/src/subscriptions.ts index c89f502c..a1d69f84 100644 --- a/packages/experiment-tag/src/subscriptions.ts +++ b/packages/experiment-tag/src/subscriptions.ts @@ -4,12 +4,18 @@ import { DefaultWebExperimentClient, INJECT_ACTION } from './experiment'; import { MessageBus, MessagePayloads, - AnalyticsEventPayload, + ElementAppearedPayload, ManualTriggerPayload, MessageType, } from './message-bus'; import { DebouncedMutationManager } from './mutation-manager'; -import { PageObject, PageObjects } from './types'; +import { + ElementAppearedTriggerValue, + ElementVisibleTriggerValue, + ManualTriggerValue, + PageObject, + PageObjects, +} from './types'; const evaluationEngine = new EvaluationEngine(); @@ -31,6 +37,10 @@ export class SubscriptionManager { private pageChangeSubscribers: Set<(event: PageChangeEvent) => void> = new Set(); private lastNotifiedActivePages: PageObjects = {}; + private intersectionObservers: Map = new Map(); + private elementVisibilityState: Map = new Map(); + private elementAppearedState: Map = new Map(); + private activeElementSelectors: Set = new Set(); constructor( webExperimentClient: DefaultWebExperimentClient, @@ -54,8 +64,12 @@ export class SubscriptionManager { if (this.options.useDefaultNavigationHandler) { this.setupLocationChangePublisher(); } - // this.setupMutationObserverPublisher(); + this.setupMutationObserverPublisher(); + this.setupVisibilityPublisher(); this.setupPageObjectSubscriptions(); + this.setupUrlChangeReset(); + // Initial check for elements that already exist + this.checkInitialElements(); }; /** @@ -162,17 +176,202 @@ export class SubscriptionManager { } }; + private setupUrlChangeReset = () => { + // Reset element state on URL navigation + this.messageBus.subscribe('url_change', () => { + this.elementAppearedState.clear(); + this.activeElementSelectors.clear(); + const elementSelectors = this.getElementSelectors(); + elementSelectors.forEach((selector) => + this.activeElementSelectors.add(selector), + ); + this.setupVisibilityPublisher(); + this.checkInitialElements(); + }); + }; + + private checkInitialElements = () => { + // Trigger initial check for element_appeared triggers + this.messageBus.publish('element_appeared', { mutationList: [] }); + }; + + private getElementSelectors(): Set { + const selectors = new Set(); + + for (const pages of Object.values(this.pageObjects)) { + for (const page of Object.values(pages)) { + if ( + page.trigger_type === 'element_appeared' || + page.trigger_type === 'element_visible' + ) { + const triggerValue = page.trigger_value as + | ElementAppearedTriggerValue + | ElementVisibleTriggerValue; + const selector = triggerValue.selector; + if (selector) { + selectors.add(selector); + } + } + } + } + + return selectors; + } + + private isMutationRelevantToSelector( + mutationList: MutationRecord[], + selector: string, + ): boolean { + for (const mutation of mutationList) { + // Check if any added nodes match the selector + if (mutation.addedNodes.length > 0) { + for (const node of Array.from(mutation.addedNodes)) { + if (node instanceof Element) { + try { + // Check if the added node itself matches + if (node.matches(selector)) { + return true; + } + // Check if any descendant matches + if (node.querySelector(selector)) { + return true; + } + } catch (e) { + // Invalid selector, skip + continue; + } + } + } + } + + // Check if mutation target or its ancestors/descendants match + if (mutation.target instanceof Element) { + try { + // Check if target matches + if (mutation.target.matches(selector)) { + return true; + } + // Check if target contains matching elements + if (mutation.target.querySelector(selector)) { + return true; + } + } catch (e) { + // Invalid selector, skip + continue; + } + } + } + + return false; + } + private setupMutationObserverPublisher = () => { + this.activeElementSelectors = this.getElementSelectors(); + + // Create filter function that checks against active selectors (dynamic) + // As elements appear and are removed from activeElementSelectors, + // fewer mutations will pass the filter, improving performance over time + const filters = + this.activeElementSelectors.size > 0 + ? [ + (mutation: MutationRecord) => { + // Check against active selectors only (not already appeared) + return Array.from(this.activeElementSelectors).some((selector) => + this.isMutationRelevantToSelector([mutation], selector), + ); + }, + ] + : []; + const mutationManager = new DebouncedMutationManager( this.globalScope.document.documentElement, (mutationList) => { this.messageBus.publish('element_appeared', { mutationList }); }, - [], + filters, ); return mutationManager.observe(); }; + private setupVisibilityPublisher = () => { + // Set up IntersectionObservers for each element_visible page object + for (const pages of Object.values(this.pageObjects)) { + for (const page of Object.values(pages)) { + if (page.trigger_type === 'element_visible') { + const triggerValue = page.trigger_value as ElementVisibleTriggerValue; + const selector = triggerValue.selector; + const visibilityRatio = triggerValue.visibilityRatio ?? 0; + + // Create unique key for this selector + threshold combination + const observerKey = `${selector}:${visibilityRatio}`; + + // Skip if we already have an observer for this selector + threshold + if (this.intersectionObservers.has(observerKey)) { + continue; + } + + // Create IntersectionObserver for this threshold + const observer = new IntersectionObserver( + (entries) => { + entries.forEach((entry) => { + const isVisible = entry.intersectionRatio >= visibilityRatio; + + // Update visibility state + this.elementVisibilityState.set(observerKey, isVisible); + + // If element becomes visible, disconnect observer (one-time trigger) + if (isVisible) { + observer.disconnect(); + this.intersectionObservers.delete(observerKey); + + // Publish element_visible event + this.messageBus.publish('element_visible', { + mutationList: [], + }); + } + }); + }, + { + threshold: visibilityRatio, + }, + ); + + this.intersectionObservers.set(observerKey, observer); + + // Observe the element if it exists + const element = this.globalScope.document.querySelector(selector); + if (element) { + observer.observe(element); + } + } + } + } + + // Re-check for elements on mutations (in case they appear later) + this.messageBus.subscribe('element_appeared', (payload) => { + const { mutationList } = payload; + + for (const [ + observerKey, + observer, + ] of this.intersectionObservers.entries()) { + const [selector] = observerKey.split(':'); + + // Check if mutation is relevant (or if it's the initial check with empty list) + const isRelevant = + mutationList.length === 0 || + this.isMutationRelevantToSelector(mutationList, selector); + + if (isRelevant) { + const element = this.globalScope.document.querySelector(selector); + if (element) { + observer.observe(element); + } + } + } + }); + }; + private setupLocationChangePublisher = () => { // Add URL change listener for back/forward navigation this.globalScope.addEventListener('popstate', () => { @@ -236,33 +435,71 @@ export class SubscriptionManager { case 'url_change': return true; - case 'manual': - return ( - (message as ManualTriggerPayload).name === page.trigger_value.name - ); - - case 'analytics_event': { - const eventMessage = message as AnalyticsEventPayload; - return ( - eventMessage.event_type === page.trigger_value.event_type && - Object.entries(page.trigger_value.event_properties || {}).every( - ([key, value]) => eventMessage.event_properties[key] === value, - ) - ); + case 'manual': { + const triggerValue = page.trigger_value as ManualTriggerValue; + return (message as ManualTriggerPayload).name === triggerValue.name; } + // case 'analytics_event': { + // const eventMessage = message as AnalyticsEventPayload; + // return ( + // eventMessage.event_type === page.trigger_value.event_type && + // Object.entries(page.trigger_value.event_properties || {}).every( + // ([key, value]) => eventMessage.event_properties[key] === value, + // ) + // ); + // } + case 'element_appeared': { - // const mutationMessage = message as DomMutationPayload; - const element = this.globalScope.document.querySelector( - page.trigger_value.selector as string, - ); + const triggerValue = page.trigger_value as ElementAppearedTriggerValue; + const selector = triggerValue.selector; + + // Check if we've already marked this element as appeared + if (this.elementAppearedState.get(selector)) { + return true; + } + + // Check if mutation is relevant to this selector before querying DOM + // Skip this check if mutationList is empty (initial check) + const elementAppearedMessage = message as ElementAppearedPayload; + if ( + elementAppearedMessage.mutationList.length > 0 && + !this.isMutationRelevantToSelector( + elementAppearedMessage.mutationList, + selector, + ) + ) { + return false; + } + + // Check if element exists and is not hidden + const element = this.globalScope.document.querySelector(selector); if (element) { const style = window.getComputedStyle(element); - return style.display !== 'none' && style.visibility !== 'hidden'; + const hasAppeared = + style.display !== 'none' && style.visibility !== 'hidden'; + + // Once it appears, remember it and remove from active checking + if (hasAppeared) { + this.elementAppearedState.set(selector, true); + this.activeElementSelectors.delete(selector); + } + + return hasAppeared; } return false; } + case 'element_visible': { + const triggerValue = page.trigger_value as ElementVisibleTriggerValue; + const selector = triggerValue.selector; + const visibilityRatio = triggerValue.visibilityRatio ?? 0; + const observerKey = `${selector}:${visibilityRatio}`; + + // Check stored visibility state from IntersectionObserver + return this.elementVisibilityState.get(observerKey) ?? false; + } + default: return false; } diff --git a/packages/experiment-tag/src/types.ts b/packages/experiment-tag/src/types.ts index 24f343d5..42639506 100644 --- a/packages/experiment-tag/src/types.ts +++ b/packages/experiment-tag/src/types.ts @@ -2,7 +2,6 @@ import { EvaluationCondition } from '@amplitude/experiment-core'; import { ExperimentConfig, ExperimentUser, - Variant, } from '@amplitude/experiment-js-client'; import { ExperimentClient, Variants } from '@amplitude/experiment-js-client'; @@ -33,12 +32,29 @@ export type PreviewState = { previewFlags: Record; }; +export interface ElementAppearedTriggerValue { + selector: string; +} + +export interface ElementVisibleTriggerValue { + selector: string; + visibilityRatio?: number; +} + +export interface ManualTriggerValue { + name: string; +} + export type PageObject = { id: string; name: string; conditions?: EvaluationCondition[][]; trigger_type: MessageType; - trigger_value: Record; + trigger_value: + | ElementAppearedTriggerValue + | ElementVisibleTriggerValue + | ManualTriggerValue + | Record; }; export type PageObjects = { [flagKey: string]: { [id: string]: PageObject } }; @@ -79,6 +95,8 @@ export interface WebExperimentClient { getActivePages(): PageObjects; setRedirectHandler(handler: (url: string) => void): void; + + activate(name: string): void; } export type WebExperimentUser = { diff --git a/packages/experiment-tag/test/experiment.test.ts b/packages/experiment-tag/test/experiment.test.ts index d6844f05..42b5c1bd 100644 --- a/packages/experiment-tag/test/experiment.test.ts +++ b/packages/experiment-tag/test/experiment.test.ts @@ -26,6 +26,36 @@ jest.mock('src/util/messenger', () => { }; }); +// Mock MutationObserver for tests +global.MutationObserver = class { + observe = jest.fn(); + disconnect = jest.fn(); + takeRecords = jest.fn(() => []); + + constructor(callback: MutationCallback) { + // do nothing + } +} as any; + +// Mock IntersectionObserver for tests +global.IntersectionObserver = class { + observe = jest.fn(); + unobserve = jest.fn(); + disconnect = jest.fn(); + takeRecords = jest.fn(() => []); + + constructor( + callback: IntersectionObserverCallback, + options?: IntersectionObserverInit, + ) { + // do nothing + } + + readonly root = null; + readonly rootMargin = ''; + readonly thresholds = []; +} as any; + const newMockGlobal = (overrides?: Record) => { const createStorageMock = () => { let store: Record = {}; @@ -49,7 +79,18 @@ const newMockGlobal = (overrides?: Record) => { const baseGlobal = { localStorage: createStorageMock(), sessionStorage: createStorageMock(), - document: { referrer: '' }, + document: { + referrer: '', + documentElement: { + nodeType: 1, + nodeName: 'HTML', + }, + querySelector: jest.fn(), + createElement: jest.fn(), + head: { + appendChild: jest.fn(), + }, + }, history: { replaceState: jest.fn() }, addEventListener: jest.fn(), experimentIntegration: { @@ -73,6 +114,8 @@ const newMockGlobal = (overrides?: Record) => { host: 'test.com', replace: jest.fn(), }, + innerHeight: 768, + innerWidth: 1024, }; baseGlobal.location.replace = jest.fn((url) => { From ca355c08bcb59cce41f80fe83b4161c0767e9d97 Mon Sep 17 00:00:00 2001 From: Tim Yiu <137842098+tyiuhc@users.noreply.github.com> Date: Wed, 17 Dec 2025 17:05:43 -0800 Subject: [PATCH 2/2] add comment --- packages/experiment-tag/src/subscriptions.ts | 1 + 1 file changed, 1 insertion(+) diff --git a/packages/experiment-tag/src/subscriptions.ts b/packages/experiment-tag/src/subscriptions.ts index a1d69f84..c62c3b9b 100644 --- a/packages/experiment-tag/src/subscriptions.ts +++ b/packages/experiment-tag/src/subscriptions.ts @@ -176,6 +176,7 @@ export class SubscriptionManager { } }; + // TODO: to cleanup and centralize state management private setupUrlChangeReset = () => { // Reset element state on URL navigation this.messageBus.subscribe('url_change', () => {