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
8 changes: 8 additions & 0 deletions packages/experiment-tag/src/experiment.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
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 @@ -13,12 +13,14 @@ type Subscriber<T extends MessageType> = {
};

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;
Expand Down
2 changes: 1 addition & 1 deletion packages/experiment-tag/src/mutation-manager.ts
Original file line number Diff line number Diff line change
Expand Up @@ -60,7 +60,7 @@ const DEFAULT_OPTIONS = {
attributes: true,
attributeFilter: ['style', 'class'],
},
debounceMs: 150,
debounceMs: 100,
} satisfies DebouncedMutationManagerOptions;

export class DebouncedMutationManager {
Expand Down
282 changes: 260 additions & 22 deletions packages/experiment-tag/src/subscriptions.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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();

Expand All @@ -31,6 +37,10 @@ export class SubscriptionManager {
private pageChangeSubscribers: Set<(event: PageChangeEvent) => void> =
new Set();
private lastNotifiedActivePages: PageObjects = {};
private intersectionObservers: Map<string, IntersectionObserver> = new Map();
private elementVisibilityState: Map<string, boolean> = new Map();
private elementAppearedState: Map<string, boolean> = new Map();
private activeElementSelectors: Set<string> = new Set();

constructor(
webExperimentClient: DefaultWebExperimentClient,
Expand All @@ -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();
};

/**
Expand Down Expand Up @@ -162,17 +176,203 @@ export class SubscriptionManager {
}
};

// TODO: to cleanup and centralize state management
private setupUrlChangeReset = () => {
// Reset element state on URL navigation
this.messageBus.subscribe('url_change', () => {
Copy link

Choose a reason for hiding this comment

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

URL-change handling isn’t idempotent: stale visibility state/observers persist and event listeners accumulate. Consider a centralized URL-change reset that disconnects IntersectionObservers, clears elementVisibilityState, and guarantees a single reusable element_appeared subscription (or add per-listener unsubscribe in MessageBus).

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

👍 Helpful? React to give us feedback.

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<string> {
const selectors = new Set<string>();

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)) {
Copy link
Collaborator

Choose a reason for hiding this comment

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

@tyiuhc From a very brief google search, it seems that node.querySelector needs to traverse the whole document anyways so you might as well just use document.querySelector(selector) once instead of looping through these mutation changes.

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(':');
Copy link

Choose a reason for hiding this comment

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

Splitting observerKey on : breaks selectors with colons (e.g., input:checked, div:nth-child(2)). Consider splitting at the last colon so the selector stays intact.

Suggested change
const [selector] = observerKey.split(':');
const selector = observerKey.slice(0, observerKey.lastIndexOf(':'));

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

👍 Helpful? React to give us feedback.


// 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', () => {
Expand Down Expand Up @@ -236,33 +436,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;
}
Expand Down
Loading
Loading