From 6620cbe34e5779820a309b01285e60d64cf297ce Mon Sep 17 00:00:00 2001 From: tyiuhc Date: Fri, 18 Oct 2024 16:16:09 -0700 Subject: [PATCH] fix: Dedupe web experiment exposures if URL is unchanged --- packages/experiment-tag/src/experiment.ts | 56 +++++++++++++------ .../experiment-tag/test/experiment.test.ts | 4 +- 2 files changed, 42 insertions(+), 18 deletions(-) diff --git a/packages/experiment-tag/src/experiment.ts b/packages/experiment-tag/src/experiment.ts index ec9fa86..7c9d64c 100644 --- a/packages/experiment-tag/src/experiment.ts +++ b/packages/experiment-tag/src/experiment.ts @@ -26,7 +26,9 @@ import { const appliedInjections: Set = 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(); @@ -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 { @@ -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'; @@ -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); }; @@ -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 = () => { @@ -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 = () => { @@ -302,25 +310,23 @@ 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()); - + 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 + // Revert mutations and apply variants if the URL has changed revertMutations(); applyVariants(globalScope.webExperiment.all()); - + previousUrl = globalScope.location.href; return result; }; }; @@ -336,3 +342,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; + } +}; diff --git a/packages/experiment-tag/test/experiment.test.ts b/packages/experiment-tag/test/experiment.test.ts index be21024..d9d5c1f 100644 --- a/packages/experiment-tag/test/experiment.test.ts +++ b/packages/experiment-tag/test/experiment.test.ts @@ -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([ @@ -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([