Skip to content

Commit

Permalink
fix: Dedupe web experiment exposures and variant actions if URL is un…
Browse files Browse the repository at this point in the history
…changed
  • Loading branch information
tyiuhc committed Oct 18, 2024
1 parent 1a39eda commit f0e0a21
Show file tree
Hide file tree
Showing 2 changed files with 50 additions and 22 deletions.
68 changes: 48 additions & 20 deletions packages/experiment-tag/src/experiment.ts
Original file line number Diff line number Diff line change
Expand Up @@ -26,7 +26,9 @@ import {

const appliedInjections: Set<string> = new Set();
const appliedMutations: MutationController[] = [];
let previousUrl: string | undefined = undefined;
let previousUrl: string | undefined;
// Cache to track exposure for the current URL, should be cleared on URL change
let urlExposureCache: { [url: string]: { [key: string]: string | undefined } };

export const initializeExperiment = (apiKey: string, initialFlags: string) => {
const globalScope = getGlobalScope();
Expand All @@ -37,7 +39,8 @@ export const initializeExperiment = (apiKey: string, initialFlags: string) => {
if (!isLocalStorageAvailable() || !globalScope) {
return;
}

previousUrl = undefined;
urlExposureCache = {};
const experimentStorageName = `EXP_${apiKey.slice(0, 10)}`;
let user: ExperimentUser;
try {
Expand Down Expand Up @@ -132,6 +135,12 @@ const applyVariants = (variants: Variants | undefined) => {
if (!globalScope) {
return;
}
const currentUrl = urlWithoutParamsAndAnchor(globalScope.location.href);
// Initialize the cache if on a new URL
if (!urlExposureCache?.[currentUrl]) {
urlExposureCache = {};
urlExposureCache[currentUrl] = {};
}
for (const key in variants) {
const variant = variants[key];
const isWebExperimentation = variant.metadata?.deliveryMethod === 'web';
Expand Down Expand Up @@ -173,18 +182,21 @@ const handleRedirect = (action, key: string, variant: Variant) => {
const redirectUrl = action?.data?.url;

const currentUrl = urlWithoutParamsAndAnchor(globalScope.location.href);
const shouldTrackExposure =
(variant.metadata?.['trackExposure'] as boolean) ?? true;

// prevent infinite redirection loop
if (currentUrl === referrerUrl) {
return;
}

const targetUrl = concatenateQueryParamsOf(
globalScope.location.href,
redirectUrl,
);
shouldTrackExposure && globalScope.webExperiment.exposure(key);

exposureWithDedupe(key, variant);

// set previous url - relevant for SPA if redirect happens before push/replaceState is complete
previousUrl = globalScope.location.href;
// perform redirection
globalScope.location.replace(targetUrl);
};
Expand All @@ -198,9 +210,7 @@ const handleMutate = (action, key: string, variant: Variant) => {
mutations.forEach((m) => {
appliedMutations.push(mutate.declarative(m));
});
const shouldTrackExposure =
(variant.metadata?.['trackExposure'] as boolean) ?? true;
shouldTrackExposure && globalScope.webExperiment.exposure(key);
exposureWithDedupe(key, variant);
};

const revertMutations = () => {
Expand Down Expand Up @@ -279,9 +289,7 @@ const handleInject = (action, key: string, variant: Variant) => {
appliedInjections.delete(id);
},
});
const shouldTrackExposure =
(variant.metadata?.['trackExposure'] as boolean) ?? true;
shouldTrackExposure && globalScope.webExperiment.exposure(key);
exposureWithDedupe(key, variant);
};

export const setUrlChangeListener = () => {
Expand All @@ -302,25 +310,27 @@ export const setUrlChangeListener = () => {

// Wrapper for pushState
history.pushState = function (...args) {
previousUrl = globalScope.location.href;
// Call the original pushState
const result = originalPushState.apply(this, args);
// Revert mutations and apply variants after pushing state
revertMutations();
applyVariants(globalScope.webExperiment.all());

if (previousUrl !== globalScope.location.href) {
revertMutations();
applyVariants(globalScope.webExperiment.all());
}
previousUrl = globalScope.location.href;
return result;
};

// Wrapper for replaceState
history.replaceState = function (...args) {
previousUrl = globalScope.location.href;
// Call the original replaceState
const result = originalReplaceState.apply(this, args);
// Revert mutations and apply variants after replacing state
revertMutations();
applyVariants(globalScope.webExperiment.all());

// Revert mutations and apply variants if the URL has changed
if (previousUrl !== globalScope.location.href) {
revertMutations();
applyVariants(globalScope.webExperiment.all());
}
previousUrl = globalScope.location.href;
return result;
};
};
Expand All @@ -336,3 +346,21 @@ const isPageTargetingSegment = (segment: EvaluationSegment) => {
segment.metadata?.segmentName === 'Page is excluded')
);
};

const exposureWithDedupe = (key: string, variant: Variant) => {
const globalScope = getGlobalScope();
if (!globalScope) return;

const shouldTrackVariant = variant.metadata?.['trackExposure'] ?? true;
const currentUrl = urlWithoutParamsAndAnchor(globalScope.location.href);

// if on the same base URL, only track exposure if variant has changed or has not been tracked
const hasTrackedVariant =
urlExposureCache?.[currentUrl]?.[key] === variant.key;
const shouldTrackExposure = shouldTrackVariant && !hasTrackedVariant;

if (shouldTrackExposure) {
globalScope.webExperiment.exposure(key);
urlExposureCache[currentUrl][key] = variant.key;
}
};
4 changes: 2 additions & 2 deletions packages/experiment-tag/test/experiment.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -116,7 +116,7 @@ describe('initializeExperiment', () => {
expect(mockGlobal.localStorage.getItem).toHaveBeenCalledTimes(0);
});

test('should redirect and call exposure', () => {
test('treatment variant on control page - should redirect and call exposure', () => {
initializeExperiment(
'3',
JSON.stringify([
Expand Down Expand Up @@ -181,7 +181,7 @@ describe('initializeExperiment', () => {
expect(mockExposure).toHaveBeenCalledWith('test');
});

test('should not redirect but call exposure', () => {
test('control variant on control page - should not redirect but call exposure', () => {
initializeExperiment(
'4',
JSON.stringify([
Expand Down

0 comments on commit f0e0a21

Please sign in to comment.