From 8203e92657feaee218fdac85788b90f22c5bbfe0 Mon Sep 17 00:00:00 2001 From: Peter Zhu Date: Thu, 31 Jul 2025 12:52:08 -0700 Subject: [PATCH 1/9] support chrome ext --- packages/experiment-tag/src/experiment.ts | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/packages/experiment-tag/src/experiment.ts b/packages/experiment-tag/src/experiment.ts index cc279fb4..eccc5c54 100644 --- a/packages/experiment-tag/src/experiment.ts +++ b/packages/experiment-tag/src/experiment.ts @@ -67,7 +67,7 @@ export class DefaultWebExperimentClient implements WebExperimentClient { [flagKey: string]: string | undefined; // variant }; } = {}; - private flagVariantMap: { + private flagVariantMap: { // Also used by chrome extension [flagKey: string]: { [variantKey: string]: Variant; }; @@ -750,7 +750,7 @@ export class DefaultWebExperimentClient implements WebExperimentClient { } else { this.experimentClient.exposure(key); } - this.urlExposureCache[currentUrl][key] = variant.key; + (this.urlExposureCache[currentUrl] ??= {})[key] = variant.key; } } @@ -768,6 +768,7 @@ export class DefaultWebExperimentClient implements WebExperimentClient { } } + // Also used by chrome extension updateActivePages(flagKey: string, page: PageObject, isActive: boolean) { if (!this.activePages[flagKey]) { this.activePages[flagKey] = {}; From c74fb88728e7484b33618fbde5644b69bfe8a75a Mon Sep 17 00:00:00 2001 From: Peter Zhu Date: Thu, 31 Jul 2025 12:59:23 -0700 Subject: [PATCH 2/9] add messenger and preview new flag --- packages/experiment-tag/src/experiment.ts | 25 ++++++++++++++++++- packages/experiment-tag/src/util/messenger.ts | 24 ++++++++++++++++-- 2 files changed, 46 insertions(+), 3 deletions(-) diff --git a/packages/experiment-tag/src/experiment.ts b/packages/experiment-tag/src/experiment.ts index eccc5c54..c4abcaf3 100644 --- a/packages/experiment-tag/src/experiment.ts +++ b/packages/experiment-tag/src/experiment.ts @@ -193,9 +193,13 @@ export class DefaultWebExperimentClient implements WebExperimentClient { ); this.subscriptionManager.initSubscriptions(); + // if in preview mode, listen for ForceVariant messages + if (urlParams['PREVIEW']) { + WindowMessenger.setup(this); + } // if in visual edit mode, remove the query param if (this.isVisualEditorMode) { - WindowMessenger.setup(); + WindowMessenger.setup(this); this.globalScope.history.replaceState( {}, '', @@ -517,6 +521,25 @@ export class DefaultWebExperimentClient implements WebExperimentClient { this.customRedirectHandler = handler; } + public previewNewFlagAndVariant( + flagKey: string, + pageViewObject: PageObject, + variantsToFlags: Record, + variantKey: string, + ) { + const urlParams = getUrlParams(); + if (urlParams['PREVIEW']) { + this.globalScope.history.replaceState( + {}, + '', + removeQueryParams(this.globalScope.location.href, ['PREVIEW', flagKey]), + ); + } + this.updateActivePages(flagKey, pageViewObject, true); + this.flagVariantMap[flagKey] = variantsToFlags; + this.previewVariants({ keyToVariant: { [flagKey]: variantKey } }); + } + private async fetchRemoteFlags() { try { // eslint-disable-next-line @typescript-eslint/ban-ts-comment diff --git a/packages/experiment-tag/src/util/messenger.ts b/packages/experiment-tag/src/util/messenger.ts index b933cc1f..acfd2458 100644 --- a/packages/experiment-tag/src/util/messenger.ts +++ b/packages/experiment-tag/src/util/messenger.ts @@ -1,14 +1,23 @@ import { getGlobalScope } from '@amplitude/experiment-core'; +import { Variant } from '@amplitude/experiment-js-client'; +import { DefaultWebExperimentClient } from 'experiment'; +import { PageObject } from 'types'; export class WindowMessenger { - static setup() { + static setup(webExperimentClient: DefaultWebExperimentClient) { let state: 'closed' | 'opening' | 'open' = 'closed'; getGlobalScope()?.addEventListener( 'message', ( e: MessageEvent<{ type: string; - context: { injectSrc: string }; + context: { + flagKey: string; + pageViewObject: PageObject; + variantKey: string; + variants: Variant[]; + injectSrc: string; + }; }>, ) => { const match = /^.*\.amplitude\.com$/; @@ -36,6 +45,17 @@ export class WindowMessenger { .catch(() => { state = 'closed'; }); + } else if (e.data.type === 'ForceVariant') { + const variantsToFlags = e.data.context.variants.reduce((acc, variant) => { + if (variant.key) { + acc[variant.key] = variant; + } + return acc; + }, {} as Record); + const flagKey = e.data.context.flagKey; + const pageViewObject = e.data.context.pageViewObject; + const variantKey = e.data.context.variantKey; + webExperimentClient.previewNewFlagAndVariant(flagKey, pageViewObject, variantsToFlags, variantKey); } }, ); From 336ee8645b19263b9bf1a493e975476fe20eb7aa Mon Sep 17 00:00:00 2001 From: Peter Zhu Date: Thu, 31 Jul 2025 12:59:42 -0700 Subject: [PATCH 3/9] alter visibility --- packages/experiment-tag/src/experiment.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/experiment-tag/src/experiment.ts b/packages/experiment-tag/src/experiment.ts index c4abcaf3..6a729f80 100644 --- a/packages/experiment-tag/src/experiment.ts +++ b/packages/experiment-tag/src/experiment.ts @@ -521,7 +521,7 @@ export class DefaultWebExperimentClient implements WebExperimentClient { this.customRedirectHandler = handler; } - public previewNewFlagAndVariant( + previewNewFlagAndVariant( flagKey: string, pageViewObject: PageObject, variantsToFlags: Record, From c96f105716bbd36254766c77a783fa02c278c10a Mon Sep 17 00:00:00 2001 From: Peter Zhu Date: Thu, 31 Jul 2025 13:42:36 -0700 Subject: [PATCH 4/9] lint --- packages/experiment-tag/src/experiment.ts | 3 ++- packages/experiment-tag/src/util/messenger.ts | 22 +++++++++++++------ 2 files changed, 17 insertions(+), 8 deletions(-) diff --git a/packages/experiment-tag/src/experiment.ts b/packages/experiment-tag/src/experiment.ts index 6a729f80..d2b855fe 100644 --- a/packages/experiment-tag/src/experiment.ts +++ b/packages/experiment-tag/src/experiment.ts @@ -67,7 +67,8 @@ export class DefaultWebExperimentClient implements WebExperimentClient { [flagKey: string]: string | undefined; // variant }; } = {}; - private flagVariantMap: { // Also used by chrome extension + // Also used by chrome extension + private flagVariantMap: { [flagKey: string]: { [variantKey: string]: Variant; }; diff --git a/packages/experiment-tag/src/util/messenger.ts b/packages/experiment-tag/src/util/messenger.ts index acfd2458..ebc7cf27 100644 --- a/packages/experiment-tag/src/util/messenger.ts +++ b/packages/experiment-tag/src/util/messenger.ts @@ -46,16 +46,24 @@ export class WindowMessenger { state = 'closed'; }); } else if (e.data.type === 'ForceVariant') { - const variantsToFlags = e.data.context.variants.reduce((acc, variant) => { - if (variant.key) { - acc[variant.key] = variant; - } - return acc; - }, {} as Record); + const variantsToFlags = e.data.context.variants.reduce( + (acc, variant) => { + if (variant.key) { + acc[variant.key] = variant; + } + return acc; + }, + {} as Record, + ); const flagKey = e.data.context.flagKey; const pageViewObject = e.data.context.pageViewObject; const variantKey = e.data.context.variantKey; - webExperimentClient.previewNewFlagAndVariant(flagKey, pageViewObject, variantsToFlags, variantKey); + webExperimentClient.previewNewFlagAndVariant( + flagKey, + pageViewObject, + variantsToFlags, + variantKey, + ); } }, ); From 4f2ad3e7d001d90fe0ebc0f9d4aaa96e3a33158f Mon Sep 17 00:00:00 2001 From: Peter Zhu Date: Thu, 31 Jul 2025 13:48:54 -0700 Subject: [PATCH 5/9] relative import, rename var --- packages/experiment-tag/src/experiment.ts | 4 ++-- packages/experiment-tag/src/util/messenger.ts | 8 ++++---- 2 files changed, 6 insertions(+), 6 deletions(-) diff --git a/packages/experiment-tag/src/experiment.ts b/packages/experiment-tag/src/experiment.ts index d2b855fe..f2065db0 100644 --- a/packages/experiment-tag/src/experiment.ts +++ b/packages/experiment-tag/src/experiment.ts @@ -525,7 +525,7 @@ export class DefaultWebExperimentClient implements WebExperimentClient { previewNewFlagAndVariant( flagKey: string, pageViewObject: PageObject, - variantsToFlags: Record, + variants: Record, variantKey: string, ) { const urlParams = getUrlParams(); @@ -537,7 +537,7 @@ export class DefaultWebExperimentClient implements WebExperimentClient { ); } this.updateActivePages(flagKey, pageViewObject, true); - this.flagVariantMap[flagKey] = variantsToFlags; + this.flagVariantMap[flagKey] = variants; this.previewVariants({ keyToVariant: { [flagKey]: variantKey } }); } diff --git a/packages/experiment-tag/src/util/messenger.ts b/packages/experiment-tag/src/util/messenger.ts index ebc7cf27..821011c0 100644 --- a/packages/experiment-tag/src/util/messenger.ts +++ b/packages/experiment-tag/src/util/messenger.ts @@ -1,7 +1,7 @@ import { getGlobalScope } from '@amplitude/experiment-core'; import { Variant } from '@amplitude/experiment-js-client'; -import { DefaultWebExperimentClient } from 'experiment'; -import { PageObject } from 'types'; +import { PageObject } from '../types'; +import { DefaultWebExperimentClient } from '../experiment'; export class WindowMessenger { static setup(webExperimentClient: DefaultWebExperimentClient) { @@ -46,7 +46,7 @@ export class WindowMessenger { state = 'closed'; }); } else if (e.data.type === 'ForceVariant') { - const variantsToFlags = e.data.context.variants.reduce( + const variants = e.data.context.variants.reduce( (acc, variant) => { if (variant.key) { acc[variant.key] = variant; @@ -61,7 +61,7 @@ export class WindowMessenger { webExperimentClient.previewNewFlagAndVariant( flagKey, pageViewObject, - variantsToFlags, + variants, variantKey, ); } From 67f49bf9970e5cf0d92fb8582895f3761e4766c7 Mon Sep 17 00:00:00 2001 From: Peter Zhu Date: Thu, 31 Jul 2025 17:59:03 -0700 Subject: [PATCH 6/9] message back for doneness --- packages/experiment-tag/src/util/messenger.ts | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/packages/experiment-tag/src/util/messenger.ts b/packages/experiment-tag/src/util/messenger.ts index 821011c0..68751d08 100644 --- a/packages/experiment-tag/src/util/messenger.ts +++ b/packages/experiment-tag/src/util/messenger.ts @@ -64,6 +64,10 @@ export class WindowMessenger { variants, variantKey, ); + e.source?.postMessage( + { type: 'DoneForceVariant' }, + { targetOrigin: e.origin }, + ); } }, ); From 623c0ff8df928242c589b27f1f49198dab76dc69 Mon Sep 17 00:00:00 2001 From: Peter Zhu Date: Fri, 1 Aug 2025 13:00:06 -0700 Subject: [PATCH 7/9] update gha for chrome ext release --- .github/workflows/publish-to-s3.yml | 13 +++++++++++++ scripts/upload-to-s3.js | 17 ++++++++++++++++- 2 files changed, 29 insertions(+), 1 deletion(-) diff --git a/.github/workflows/publish-to-s3.yml b/.github/workflows/publish-to-s3.yml index 736b28ee..530323a7 100644 --- a/.github/workflows/publish-to-s3.yml +++ b/.github/workflows/publish-to-s3.yml @@ -13,6 +13,11 @@ on: type: boolean required: true default: true + includeChromeExtension: + description: 'Release experiment-tag package for chrome extension' + type: boolean + required: true + default: true jobs: authorize: @@ -89,6 +94,14 @@ jobs: PACKAGES="segment-plugin" fi fi + + if [[ "${{ github.event.inputs.includeChromeExtension }}" == "true" ]]; then + if [[ -n "$PACKAGES" ]]; then + PACKAGES="$PACKAGES,chrome-extension" + else + PACKAGES="chrome-extension" + fi + fi if [[ -z "$PACKAGES" ]]; then echo "No packages selected for upload" diff --git a/scripts/upload-to-s3.js b/scripts/upload-to-s3.js index 1adc7241..8f56c8b5 100644 --- a/scripts/upload-to-s3.js +++ b/scripts/upload-to-s3.js @@ -42,6 +42,17 @@ const availablePackages = { }, ], }, + 'chrome-extension': { + name: 'experiment-tag-latest-chrome-ext-v1', + packagePath: '../packages/experiment-tag/package.json', + distPath: 'packages/experiment-tag/dist', + files: [ + { + file: 'experiment-tag-min.js', + gzipped: false, + }, + ], + }, }; // Parse the packages input @@ -108,7 +119,11 @@ const allPromises = packagesToUpload.flatMap((packageConfig) => { const body = fs.readFileSync(filePath); // Create the key with version and optional branch name - let fileName = `${packageConfig.name}-${version}`; + let fileName = `${packageConfig.name}`; + if (packageConfig.name !== availablePackages['chrome-extension'].name) { + // chrome extension is not versioned + fileName += `-${version}`; + } if (branchName) { fileName += `-${branchName}`; } From 6b8876a826f3761751562fd4d7d90b782541da27 Mon Sep 17 00:00:00 2001 From: Peter Zhu Date: Tue, 5 Aug 2025 10:59:23 -0700 Subject: [PATCH 8/9] lint --- packages/experiment-tag/src/util/messenger.ts | 18 ++++++++---------- 1 file changed, 8 insertions(+), 10 deletions(-) diff --git a/packages/experiment-tag/src/util/messenger.ts b/packages/experiment-tag/src/util/messenger.ts index 68751d08..1caf9252 100644 --- a/packages/experiment-tag/src/util/messenger.ts +++ b/packages/experiment-tag/src/util/messenger.ts @@ -1,7 +1,8 @@ import { getGlobalScope } from '@amplitude/experiment-core'; import { Variant } from '@amplitude/experiment-js-client'; -import { PageObject } from '../types'; + import { DefaultWebExperimentClient } from '../experiment'; +import { PageObject } from '../types'; export class WindowMessenger { static setup(webExperimentClient: DefaultWebExperimentClient) { @@ -46,15 +47,12 @@ export class WindowMessenger { state = 'closed'; }); } else if (e.data.type === 'ForceVariant') { - const variants = e.data.context.variants.reduce( - (acc, variant) => { - if (variant.key) { - acc[variant.key] = variant; - } - return acc; - }, - {} as Record, - ); + const variants = e.data.context.variants.reduce((acc, variant) => { + if (variant.key) { + acc[variant.key] = variant; + } + return acc; + }, {} as Record); const flagKey = e.data.context.flagKey; const pageViewObject = e.data.context.pageViewObject; const variantKey = e.data.context.variantKey; From 48bcf856b191e3a08bba09a4efef22dca2e639f9 Mon Sep 17 00:00:00 2001 From: Tim Yiu <137842098+tyiuhc@users.noreply.github.com> Date: Tue, 23 Sep 2025 11:07:45 -0700 Subject: [PATCH 9/9] fix: refactor preview mode (#211) --- packages/experiment-tag/src/experiment.ts | 175 ++++++++---------- packages/experiment-tag/src/index.ts | 48 ++++- packages/experiment-tag/src/preview/http.ts | 53 ++++++ .../experiment-tag/src/preview/preview-api.ts | 54 ++++++ .../experiment-tag/src/preview/preview.ts | 43 +++-- packages/experiment-tag/src/subscriptions.ts | 5 + packages/experiment-tag/src/types.ts | 5 + .../experiment-tag/src/util/anti-flicker.ts | 16 ++ packages/experiment-tag/src/util/messenger.ts | 32 +--- packages/experiment-tag/src/util/url.ts | 25 ++- .../experiment-tag/test/experiment.test.ts | 13 +- 11 files changed, 311 insertions(+), 158 deletions(-) create mode 100644 packages/experiment-tag/src/preview/http.ts create mode 100644 packages/experiment-tag/src/preview/preview-api.ts create mode 100644 packages/experiment-tag/src/util/anti-flicker.ts diff --git a/packages/experiment-tag/src/experiment.ts b/packages/experiment-tag/src/experiment.ts index 643e458b..caf67cf1 100644 --- a/packages/experiment-tag/src/experiment.ts +++ b/packages/experiment-tag/src/experiment.ts @@ -29,8 +29,10 @@ import { PageObject, PageObjects, PreviewVariantsOptions, + PreviewState, RevertVariantsOptions, } from './types'; +import { applyAntiFlickerCss } from './util/anti-flicker'; import { setMarketingCookie } from './util/cookie'; import { getInjectUtils } from './util/inject-utils'; import { VISUAL_EDITOR_SESSION_KEY, WindowMessenger } from './util/messenger'; @@ -53,8 +55,7 @@ import { convertEvaluationVariantToVariant } from './util/variant'; const MUTATE_ACTION = 'mutate'; export const INJECT_ACTION = 'inject'; const REDIRECT_ACTION = 'redirect'; -const PREVIEW_MODE_PARAM = 'PREVIEW'; -export const PREVIEW_SEGMENT_NAME = 'Preview'; +export const PREVIEW_MODE_PARAM = 'PREVIEW'; export const PREVIEW_MODE_SESSION_KEY = 'amp-preview-mode'; const VISUAL_EDITOR_PARAM = 'VISUAL_EDITOR'; @@ -99,7 +100,9 @@ export class DefaultWebExperimentClient implements WebExperimentClient { private activePages: PageObjects = {}; private subscriptionManager: SubscriptionManager | undefined; private isVisualEditorMode = false; - private previewFlags: Record = {}; + // Preview mode is set by url params, postMessage or session storage, not chrome extension + isPreviewMode = false; + previewFlags: Record = {}; constructor( apiKey: string, @@ -124,21 +127,6 @@ export class DefaultWebExperimentClient implements WebExperimentClient { ...(this.globalScope.experimentConfig ?? {}), }; - const urlParams = getUrlParams(); - - let previewFlags: Record = {}; - // explicit URL params takes precedence over session storage - if (urlParams[PREVIEW_MODE_PARAM]) { - Object.keys(urlParams).forEach((key) => { - if (key !== 'PREVIEW' && urlParams[key]) { - previewFlags[key] = urlParams[key]; - } - }); - } else { - previewFlags = - getStorageItem('sessionStorage', PREVIEW_MODE_SESSION_KEY) || {}; - } - this.initialFlags.forEach((flag: EvaluationFlag) => { const { key, variants, metadata = {} } = flag; @@ -148,48 +136,11 @@ export class DefaultWebExperimentClient implements WebExperimentClient { convertEvaluationVariantToVariant(variants[variantKey]); }); - // Update initialFlags to force variant if in preview mode - if (key in previewFlags && previewFlags[key] in variants) { - this.previewFlags[key] = previewFlags[key]; - - const previewSegment = { - metadata: { segmentName: PREVIEW_SEGMENT_NAME }, - variant: previewFlags[key], - }; - - flag.segments = [previewSegment]; - metadata.evaluationMode = 'local'; - } - if (metadata.evaluationMode !== 'local') { this.remoteFlagKeys.push(key); } - - flag.metadata = metadata; }); - if (Object.keys(this.previewFlags).length > 0) { - if (urlParams[PREVIEW_MODE_PARAM]) { - setStorageItem( - 'sessionStorage', - PREVIEW_MODE_SESSION_KEY, - this.previewFlags, - ); - const previewParamsToRemove = [ - ...Object.keys(this.previewFlags), - PREVIEW_MODE_PARAM, - ]; - this.globalScope.history.replaceState( - {}, - '', - removeQueryParams( - this.globalScope.location.href, - previewParamsToRemove, - ), - ); - } - } - const initialFlagsString = JSON.stringify(this.initialFlags); // initialize the experiment @@ -235,15 +186,12 @@ export class DefaultWebExperimentClient implements WebExperimentClient { }, this.globalScope, ); + this.setupPreviewMode(urlParams); this.subscriptionManager.initSubscriptions(); - // if in preview mode, listen for ForceVariant messages - if (urlParams['PREVIEW']) { - WindowMessenger.setup(this); - } // if in visual edit mode, remove the query param if (this.isVisualEditorMode) { - WindowMessenger.setup(this); + WindowMessenger.setup(); this.globalScope.history.replaceState( {}, '', @@ -288,11 +236,14 @@ export class DefaultWebExperimentClient implements WebExperimentClient { if ( this.remoteFlagKeys.includes(flagKey) && variant.metadata?.blockingEvaluation && - Object.keys(this.activePages).includes(flagKey) + Object.keys(this.activePages).includes(flagKey) && + !this.remoteFlagKeys.every((key) => + Object.keys(this.previewFlags).includes(key), + ) ) { this.isRemoteBlocking = true; // Apply anti-flicker CSS to prevent UI flicker - this.applyAntiFlickerCss(); + applyAntiFlickerCss(); } } @@ -316,8 +267,16 @@ export class DefaultWebExperimentClient implements WebExperimentClient { // apply local variants this.applyVariants({ flagKeys: this.localFlagKeys }); + this.previewVariants({ + keyToVariant: this.previewFlags, + }); - if (this.remoteFlagKeys.length === 0) { + if ( + // do not fetch remote flags if all remote flags are in preview mode + this.remoteFlagKeys.every((key) => + Object.keys(this.previewFlags).includes(key), + ) + ) { this.isRunning = true; return; } @@ -374,11 +333,6 @@ export class DefaultWebExperimentClient implements WebExperimentClient { * @param options */ public applyVariants(options?: ApplyVariantsOptions) { - if (Object.keys(this.previewFlags).length > 0) { - showPreviewModeModal({ - flags: this.previewFlags, - }); - } const { flagKeys } = options || {}; const variants = this.getVariants(); if (Object.keys(variants).length === 0) { @@ -396,7 +350,8 @@ export class DefaultWebExperimentClient implements WebExperimentClient { this.fireStoredRedirectImpressions(); for (const key in variants) { - if (flagKeys && !flagKeys.includes(key)) { + // preview actions are handled by previewVariants + if ((flagKeys && !flagKeys.includes(key)) || this.previewFlags[key]) { continue; } const variant = variants[key]; @@ -491,6 +446,12 @@ export class DefaultWebExperimentClient implements WebExperimentClient { if (!variantObject) { return; } + if (this.isPreviewMode) { + this.exposureWithDedupe(key, variantObject, true); + showPreviewModeModal({ + flags: this.previewFlags, + }); + } const payload = variantObject.payload; if (!payload || !Array.isArray(payload)) { return; @@ -563,25 +524,6 @@ export class DefaultWebExperimentClient implements WebExperimentClient { this.customRedirectHandler = handler; } - previewNewFlagAndVariant( - flagKey: string, - pageViewObject: PageObject, - variants: Record, - variantKey: string, - ) { - const urlParams = getUrlParams(); - if (urlParams['PREVIEW']) { - this.globalScope.history.replaceState( - {}, - '', - removeQueryParams(this.globalScope.location.href, ['PREVIEW', flagKey]), - ); - } - this.updateActivePages(flagKey, pageViewObject, true); - this.flagVariantMap[flagKey] = variants; - this.previewVariants({ keyToVariant: { [flagKey]: variantKey } }); - } - private async fetchRemoteFlags() { try { // eslint-disable-next-line @typescript-eslint/ban-ts-comment @@ -819,20 +761,6 @@ export class DefaultWebExperimentClient implements WebExperimentClient { } } - private applyAntiFlickerCss() { - if (!this.globalScope.document.getElementById('amp-exp-css')) { - const id = 'amp-exp-css'; - const s = document.createElement('style'); - s.id = id; - s.innerText = - '* { visibility: hidden !important; background-image: none !important; }'; - document.head.appendChild(s); - this.globalScope.window.setTimeout(function () { - s.remove(); - }, 1000); - } - } - // Also used by chrome extension updateActivePages(flagKey: string, page: PageObject, isActive: boolean) { if (!this.activePages[flagKey]) { @@ -919,4 +847,47 @@ export class DefaultWebExperimentClient implements WebExperimentClient { removeStorageItem('sessionStorage', redirectStorageKey); } } + + private setupPreviewMode(urlParams: Record) { + // explicit URL params takes precedence over session storage + if (urlParams[PREVIEW_MODE_PARAM] === 'true') { + Object.keys(urlParams).forEach((key) => { + if (key !== PREVIEW_MODE_PARAM && urlParams[key]) { + this.previewFlags[key] = urlParams[key]; + } + }); + + setStorageItem('sessionStorage', PREVIEW_MODE_SESSION_KEY, { + previewFlags: this.previewFlags, + }); + const previewParamsToRemove = [ + ...Object.keys(this.previewFlags), + PREVIEW_MODE_PARAM, + ]; + this.globalScope.history.replaceState( + {}, + '', + removeQueryParams( + this.globalScope.location.href, + previewParamsToRemove, + ), + ); + // if in preview mode, listen for ForceVariant messages + WindowMessenger.setup(); + } else { + const previewState: PreviewState | null = getStorageItem( + 'sessionStorage', + PREVIEW_MODE_SESSION_KEY, + ); + if (previewState) { + this.previewFlags = previewState.previewFlags; + } + } + + if (Object.keys(this.previewFlags).length > 0) { + this.isPreviewMode = true; + } else { + return; + } + } } diff --git a/packages/experiment-tag/src/index.ts b/packages/experiment-tag/src/index.ts index c6953bfe..3ddf260d 100644 --- a/packages/experiment-tag/src/index.ts +++ b/packages/experiment-tag/src/index.ts @@ -1,5 +1,11 @@ +import { getGlobalScope } from '@amplitude/experiment-core'; + import { DefaultWebExperimentClient } from './experiment'; +import { HttpClient } from './preview/http'; +import { SdkPreviewApi } from './preview/preview-api'; import { WebExperimentConfig } from './types'; +import { applyAntiFlickerCss } from './util/anti-flicker'; +import { isPreviewMode } from './util/url'; export const initialize = ( apiKey: string, @@ -7,12 +13,33 @@ export const initialize = ( pageObjects: string, config: WebExperimentConfig, ): void => { - DefaultWebExperimentClient.getInstance( - apiKey, - initialFlags, - pageObjects, - config, - ) + const shouldFetchConfigs = + isPreviewMode() || getGlobalScope()?.WebExperiment.injectedByExtension; + + if (shouldFetchConfigs) { + applyAntiFlickerCss(); + fetchLatestConfigs(apiKey, config.serverZone) + .then((previewState) => { + const flags = JSON.stringify(previewState.flags); + const objects = JSON.stringify(previewState.pageViewObjects); + startClient(apiKey, flags, objects, config); + }) + .catch((error) => { + console.warn('Failed to fetch latest configs for preview:', error); + startClient(apiKey, initialFlags, pageObjects, config); + }); + } else { + startClient(apiKey, initialFlags, pageObjects, config); + } +}; + +const startClient = ( + apiKey: string, + flags: string, + objects: string, + config: WebExperimentConfig, +): void => { + DefaultWebExperimentClient.getInstance(apiKey, flags, objects, config) .start() .finally(() => { // Remove anti-flicker css if it exists @@ -20,6 +47,15 @@ export const initialize = ( }); }; +const fetchLatestConfigs = async (apiKey: string, serverZone?: string) => { + const serverUrl = + serverZone === 'EU' + ? 'https://api.lab.eu.amplitude.com' + : 'https://api.lab.amplitude.com'; + const api = new SdkPreviewApi(apiKey, serverUrl, HttpClient); + return api.getPreviewFlagsAndPageViewObjects(); +}; + export { ApplyVariantsOptions, RevertVariantsOptions, diff --git a/packages/experiment-tag/src/preview/http.ts b/packages/experiment-tag/src/preview/http.ts new file mode 100644 index 00000000..7167f379 --- /dev/null +++ b/packages/experiment-tag/src/preview/http.ts @@ -0,0 +1,53 @@ +import { safeGlobal, TimeoutError } from '@amplitude/experiment-core'; +import unfetch from 'unfetch'; + +export interface SimpleResponse { + status: number; + body: string; +} + +export interface HttpClient { + request( + url: string, + method: string, + headers: Record, + data: string | null, + timeout?: number, + ): Promise; +} + +const fetch = safeGlobal.fetch || unfetch; + +const withTimeout = ( + promise: Promise, + ms?: number, +): Promise => { + if (!ms || ms <= 0) return promise; + + return Promise.race([ + promise, + new Promise((_, reject) => + setTimeout(() => reject(new TimeoutError(`Timeout after ${ms}ms`)), ms), + ), + ]); +}; + +const makeRequest = async ( + url: string, + method: string, + headers: Record, + data: string, + timeout?: number, +): Promise => { + const request = async () => { + const response = await fetch(url, { method, headers, body: data }); + return { + status: response.status, + body: await response.text(), + }; + }; + + return withTimeout(request(), timeout); +}; + +export const HttpClient: HttpClient = { request: makeRequest }; diff --git a/packages/experiment-tag/src/preview/preview-api.ts b/packages/experiment-tag/src/preview/preview-api.ts new file mode 100644 index 00000000..47aa715c --- /dev/null +++ b/packages/experiment-tag/src/preview/preview-api.ts @@ -0,0 +1,54 @@ +import { EvaluationFlag } from '@amplitude/experiment-core'; + +import { version } from '../../package.json'; +import { PageObjects } from '../types'; + +import { HttpClient } from './http'; + +export interface PreviewApi { + getPreviewFlagsAndPageViewObjects(): Promise<{ + flags: EvaluationFlag[]; + pageViewObjects: PageObjects; + }>; +} + +export class SdkPreviewApi implements PreviewApi { + private readonly deploymentKey: string; + private readonly serverUrl: string; + private readonly httpClient: HttpClient; + + constructor( + deploymentKey: string, + serverUrl: string, + httpClient: HttpClient, + ) { + this.deploymentKey = deploymentKey; + this.serverUrl = serverUrl; + this.httpClient = httpClient; + } + + public async getPreviewFlagsAndPageViewObjects(): Promise<{ + flags: EvaluationFlag[]; + pageViewObjects: PageObjects; + }> { + const headers: Record = { + Authorization: `Api-Key ${this.deploymentKey}`, + }; + headers['X-Amp-Exp-Library'] = `experiment-tag/${version}`; + const response = await this.httpClient.request( + `${this.serverUrl}/web/v1/configs`, + 'GET', + headers, + null, + 10000, + ); + if (response.status != 200) { + throw Error(`Preview error response: status=${response.status}`); + } + const flags: EvaluationFlag[] = JSON.parse(response.body) + .flags as EvaluationFlag[]; + const pageViewObjects: PageObjects = JSON.parse(response.body) + .pageObjects as PageObjects; + return { flags, pageViewObjects }; + } +} diff --git a/packages/experiment-tag/src/preview/preview.ts b/packages/experiment-tag/src/preview/preview.ts index 7f7588e1..fd62339a 100644 --- a/packages/experiment-tag/src/preview/preview.ts +++ b/packages/experiment-tag/src/preview/preview.ts @@ -16,19 +16,11 @@ export class PreviewModeModal { } show(): void { - if (this.modal || document.getElementById('amp-preview-modal')) { + if (document.getElementById('amp-preview-modal')) { return; } - - if (document.readyState === 'loading') { - document.addEventListener('DOMContentLoaded', () => { - this.createModal(); - this.attachEventListeners(); - }); - } else { - this.createModal(); - this.attachEventListeners(); - } + this.createModal(); + this.attachEventListeners(); } hide(): void { @@ -104,8 +96,11 @@ export class PreviewModeModal { this.modal.appendChild(closeButton); this.injectStyles(); - - document.body.appendChild(this.modal); + requestAnimationFrame(() => { + if (this.modal && document.body) { + document.body.appendChild(this.modal); + } + }); } private attachEventListeners(): void { @@ -188,6 +183,9 @@ export class PreviewModeModal { color: #1a202c; font-size: 14px; white-space: nowrap; + max-width: 300px; + overflow: hidden; + text-overflow: ellipsis; } /* Base badge styles */ @@ -325,13 +323,28 @@ export function showPreviewModeModal( ): PreviewModeModal { const modal = new PreviewModeModal(options); + let documentReady = false; + let timeoutReady = false; + + const tryShow = () => { + if (documentReady && timeoutReady) { + modal.show(); + } + }; + if (document.readyState === 'loading') { document.addEventListener('DOMContentLoaded', () => { - modal.show(); + documentReady = true; + tryShow(); }); } else { - modal.show(); + documentReady = true; } + setTimeout(() => { + timeoutReady = true; + tryShow(); + }, 500); + return modal; } diff --git a/packages/experiment-tag/src/subscriptions.ts b/packages/experiment-tag/src/subscriptions.ts index a7a1b727..c89f502c 100644 --- a/packages/experiment-tag/src/subscriptions.ts +++ b/packages/experiment-tag/src/subscriptions.ts @@ -135,6 +135,11 @@ export class SubscriptionManager { }); } this.webExperimentClient.applyVariants(); + if (this.webExperimentClient.isPreviewMode) { + this.webExperimentClient.previewVariants({ + keyToVariant: this.webExperimentClient.previewFlags, + }); + } } const activePages = this.webExperimentClient.getActivePages(); diff --git a/packages/experiment-tag/src/types.ts b/packages/experiment-tag/src/types.ts index 4f6bc603..aa7b0b2f 100644 --- a/packages/experiment-tag/src/types.ts +++ b/packages/experiment-tag/src/types.ts @@ -2,6 +2,7 @@ import { EvaluationCondition } from '@amplitude/experiment-core'; import { ExperimentConfig, ExperimentUser, + Variant, } from '@amplitude/experiment-js-client'; import { ExperimentClient, Variants } from '@amplitude/experiment-js-client'; @@ -28,6 +29,10 @@ export type PreviewVariantsOptions = { keyToVariant?: Record; }; +export type PreviewState = { + previewFlags: Record; +}; + export type PageObject = { id: string; name: string; diff --git a/packages/experiment-tag/src/util/anti-flicker.ts b/packages/experiment-tag/src/util/anti-flicker.ts new file mode 100644 index 00000000..d5413dad --- /dev/null +++ b/packages/experiment-tag/src/util/anti-flicker.ts @@ -0,0 +1,16 @@ +import { getGlobalScope } from '@amplitude/experiment-core'; + +export const applyAntiFlickerCss = () => { + const globalScope = getGlobalScope(); + if (!globalScope?.document.getElementById('amp-exp-css')) { + const id = 'amp-exp-css'; + const s = document.createElement('style'); + s.id = id; + s.innerText = + '* { visibility: hidden !important; background-image: none !important; }'; + document.head.appendChild(s); + globalScope?.window.setTimeout(function () { + s.remove(); + }, 1000); + } +}; diff --git a/packages/experiment-tag/src/util/messenger.ts b/packages/experiment-tag/src/util/messenger.ts index 550ce398..97dd5db9 100644 --- a/packages/experiment-tag/src/util/messenger.ts +++ b/packages/experiment-tag/src/util/messenger.ts @@ -1,8 +1,4 @@ import { getGlobalScope } from '@amplitude/experiment-core'; -import { Variant } from '@amplitude/experiment-js-client'; - -import { DefaultWebExperimentClient } from '../experiment'; -import { PageObject } from '../types'; import { getStorageItem } from './storage'; @@ -14,7 +10,7 @@ interface VisualEditorSession { export const VISUAL_EDITOR_SESSION_KEY = 'visual-editor-state'; export class WindowMessenger { - static setup(webExperimentClient: DefaultWebExperimentClient) { + static setup() { let state: 'closed' | 'opening' | 'open' = 'closed'; // Check for existing session on setup @@ -38,12 +34,8 @@ export class WindowMessenger { e: MessageEvent<{ type: string; context: { - flagKey: string; - pageViewObject: PageObject; - variantKey: string; - variants: Variant[]; injectSrc: string; - amplitudeWindowUrl: string + amplitudeWindowUrl: string; }; }>, ) => { @@ -72,26 +64,6 @@ export class WindowMessenger { .catch(() => { state = 'closed'; }); - } else if (e.data.type === 'ForceVariant') { - const variants = e.data.context.variants.reduce((acc, variant) => { - if (variant.key) { - acc[variant.key] = variant; - } - return acc; - }, {} as Record); - const flagKey = e.data.context.flagKey; - const pageViewObject = e.data.context.pageViewObject; - const variantKey = e.data.context.variantKey; - webExperimentClient.previewNewFlagAndVariant( - flagKey, - pageViewObject, - variants, - variantKey, - ); - e.source?.postMessage( - { type: 'DoneForceVariant' }, - { targetOrigin: e.origin }, - ); } }, ); diff --git a/packages/experiment-tag/src/util/url.ts b/packages/experiment-tag/src/util/url.ts index b91257ae..dd6830f9 100644 --- a/packages/experiment-tag/src/util/url.ts +++ b/packages/experiment-tag/src/util/url.ts @@ -1,5 +1,9 @@ -import { EvaluationVariant, getGlobalScope } from '@amplitude/experiment-core'; -import { Variant } from '@amplitude/experiment-js-client'; +import { getGlobalScope } from '@amplitude/experiment-core'; + +import { PREVIEW_MODE_PARAM, PREVIEW_MODE_SESSION_KEY } from '../experiment'; +import { PreviewState } from '../types'; + +import { getStorageItem } from './storage'; export const getUrlParams = (): Record => { const globalScope = getGlobalScope(); @@ -63,3 +67,20 @@ export const concatenateQueryParamsOf = ( return resultUrlObj.toString(); }; + +export const isPreviewMode = (): boolean => { + if (getUrlParams()[PREVIEW_MODE_PARAM] === 'true') { + return true; + } + const previewState = getStorageItem( + 'sessionStorage', + PREVIEW_MODE_SESSION_KEY, + ) as PreviewState; + if ( + previewState?.previewFlags && + Object.keys(previewState.previewFlags).length > 0 + ) { + return true; + } + return false; +}; diff --git a/packages/experiment-tag/test/experiment.test.ts b/packages/experiment-tag/test/experiment.test.ts index 9b3dd8ce..059882be 100644 --- a/packages/experiment-tag/test/experiment.test.ts +++ b/packages/experiment-tag/test/experiment.test.ts @@ -3,6 +3,7 @@ import { safeGlobal } from '@amplitude/experiment-core'; import { ExperimentClient } from '@amplitude/experiment-js-client'; import { Base64 } from 'js-base64'; import { DefaultWebExperimentClient } from 'src/experiment'; +import * as antiFlickerUtils from 'src/util/anti-flicker'; import * as uuid from 'src/util/uuid'; import { stringify } from 'ts-jest'; @@ -137,7 +138,7 @@ describe('initializeExperiment', () => { // Ensure the mock is properly set mockGetGlobalScope.mockReturnValue(mockGlobal); antiFlickerSpy = jest - .spyOn(DefaultWebExperimentClient.prototype as any, 'applyAntiFlickerCss') + .spyOn(antiFlickerUtils, 'applyAntiFlickerCss') .mockImplementation(jest.fn()); }); @@ -328,7 +329,11 @@ describe('initializeExperiment', () => { '', 'http://test.com/', ); - expect(mockExposure).toHaveBeenCalledWith('test'); + expect(mockExposureInternal).toHaveBeenCalledWith('test', { + variant: { key: 'control', value: 'control' }, + source: 'local-evaluation', + hasDefaultVariant: false, + }); }); test('preview - force treatment variant when on control page', async () => { @@ -406,7 +411,7 @@ describe('initializeExperiment', () => { // @ts-ignore mockGetGlobalScope.mockReturnValue(mockGlobal); - DefaultWebExperimentClient.getInstance( + const client = DefaultWebExperimentClient.getInstance( stringify(apiKey), JSON.stringify([ createRedirectFlag('test', 'treatment', 'http://test.com/2', undefined), @@ -414,6 +419,8 @@ describe('initializeExperiment', () => { JSON.stringify(DEFAULT_PAGE_OBJECTS), ); + client.start().then(); + expect(mockGlobal.location.replace).toHaveBeenCalledTimes(0); expect(mockExposure).toHaveBeenCalledTimes(0); expect(mockGlobal.history.replaceState).toHaveBeenCalledWith(