Skip to content

Commit 6620cbe

Browse files
committed
fix: Dedupe web experiment exposures if URL is unchanged
1 parent 1a39eda commit 6620cbe

File tree

2 files changed

+42
-18
lines changed

2 files changed

+42
-18
lines changed

packages/experiment-tag/src/experiment.ts

Lines changed: 40 additions & 16 deletions
Original file line numberDiff line numberDiff line change
@@ -26,7 +26,9 @@ import {
2626

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

3133
export const initializeExperiment = (apiKey: string, initialFlags: string) => {
3234
const globalScope = getGlobalScope();
@@ -37,7 +39,8 @@ export const initializeExperiment = (apiKey: string, initialFlags: string) => {
3739
if (!isLocalStorageAvailable() || !globalScope) {
3840
return;
3941
}
40-
42+
previousUrl = undefined;
43+
urlExposureCache = {};
4144
const experimentStorageName = `EXP_${apiKey.slice(0, 10)}`;
4245
let user: ExperimentUser;
4346
try {
@@ -132,6 +135,12 @@ const applyVariants = (variants: Variants | undefined) => {
132135
if (!globalScope) {
133136
return;
134137
}
138+
const currentUrl = urlWithoutParamsAndAnchor(globalScope.location.href);
139+
// Initialize the cache if on a new URL
140+
if (!urlExposureCache?.[currentUrl]) {
141+
urlExposureCache = {};
142+
urlExposureCache[currentUrl] = {};
143+
}
135144
for (const key in variants) {
136145
const variant = variants[key];
137146
const isWebExperimentation = variant.metadata?.deliveryMethod === 'web';
@@ -173,18 +182,21 @@ const handleRedirect = (action, key: string, variant: Variant) => {
173182
const redirectUrl = action?.data?.url;
174183

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

179186
// prevent infinite redirection loop
180187
if (currentUrl === referrerUrl) {
181188
return;
182189
}
190+
183191
const targetUrl = concatenateQueryParamsOf(
184192
globalScope.location.href,
185193
redirectUrl,
186194
);
187-
shouldTrackExposure && globalScope.webExperiment.exposure(key);
195+
196+
exposureWithDedupe(key, variant);
197+
198+
// set previous url - relevant for SPA if redirect happens before push/replaceState is complete
199+
previousUrl = globalScope.location.href;
188200
// perform redirection
189201
globalScope.location.replace(targetUrl);
190202
};
@@ -198,9 +210,7 @@ const handleMutate = (action, key: string, variant: Variant) => {
198210
mutations.forEach((m) => {
199211
appliedMutations.push(mutate.declarative(m));
200212
});
201-
const shouldTrackExposure =
202-
(variant.metadata?.['trackExposure'] as boolean) ?? true;
203-
shouldTrackExposure && globalScope.webExperiment.exposure(key);
213+
exposureWithDedupe(key, variant);
204214
};
205215

206216
const revertMutations = () => {
@@ -279,9 +289,7 @@ const handleInject = (action, key: string, variant: Variant) => {
279289
appliedInjections.delete(id);
280290
},
281291
});
282-
const shouldTrackExposure =
283-
(variant.metadata?.['trackExposure'] as boolean) ?? true;
284-
shouldTrackExposure && globalScope.webExperiment.exposure(key);
292+
exposureWithDedupe(key, variant);
285293
};
286294

287295
export const setUrlChangeListener = () => {
@@ -302,25 +310,23 @@ export const setUrlChangeListener = () => {
302310

303311
// Wrapper for pushState
304312
history.pushState = function (...args) {
305-
previousUrl = globalScope.location.href;
306313
// Call the original pushState
307314
const result = originalPushState.apply(this, args);
308315
// Revert mutations and apply variants after pushing state
309316
revertMutations();
310317
applyVariants(globalScope.webExperiment.all());
311-
318+
previousUrl = globalScope.location.href;
312319
return result;
313320
};
314321

315322
// Wrapper for replaceState
316323
history.replaceState = function (...args) {
317-
previousUrl = globalScope.location.href;
318324
// Call the original replaceState
319325
const result = originalReplaceState.apply(this, args);
320-
// Revert mutations and apply variants after replacing state
326+
// Revert mutations and apply variants if the URL has changed
321327
revertMutations();
322328
applyVariants(globalScope.webExperiment.all());
323-
329+
previousUrl = globalScope.location.href;
324330
return result;
325331
};
326332
};
@@ -336,3 +342,21 @@ const isPageTargetingSegment = (segment: EvaluationSegment) => {
336342
segment.metadata?.segmentName === 'Page is excluded')
337343
);
338344
};
345+
346+
const exposureWithDedupe = (key: string, variant: Variant) => {
347+
const globalScope = getGlobalScope();
348+
if (!globalScope) return;
349+
350+
const shouldTrackVariant = variant.metadata?.['trackExposure'] ?? true;
351+
const currentUrl = urlWithoutParamsAndAnchor(globalScope.location.href);
352+
353+
// if on the same base URL, only track exposure if variant has changed or has not been tracked
354+
const hasTrackedVariant =
355+
urlExposureCache?.[currentUrl]?.[key] === variant.key;
356+
const shouldTrackExposure = shouldTrackVariant && !hasTrackedVariant;
357+
358+
if (shouldTrackExposure) {
359+
globalScope.webExperiment.exposure(key);
360+
urlExposureCache[currentUrl][key] = variant.key;
361+
}
362+
};

packages/experiment-tag/test/experiment.test.ts

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -116,7 +116,7 @@ describe('initializeExperiment', () => {
116116
expect(mockGlobal.localStorage.getItem).toHaveBeenCalledTimes(0);
117117
});
118118

119-
test('should redirect and call exposure', () => {
119+
test('treatment variant on control page - should redirect and call exposure', () => {
120120
initializeExperiment(
121121
'3',
122122
JSON.stringify([
@@ -181,7 +181,7 @@ describe('initializeExperiment', () => {
181181
expect(mockExposure).toHaveBeenCalledWith('test');
182182
});
183183

184-
test('should not redirect but call exposure', () => {
184+
test('control variant on control page - should not redirect but call exposure', () => {
185185
initializeExperiment(
186186
'4',
187187
JSON.stringify([

0 commit comments

Comments
 (0)