From d49e39c50f8409cf7c43f30fea660f7ca22e7e1b Mon Sep 17 00:00:00 2001 From: Tim Yiu <137842098+tyiuhc@users.noreply.github.com> Date: Tue, 16 Jun 2026 15:07:41 -0700 Subject: [PATCH 01/14] feat(experiment-tag): wire relay iframe on start (WEB-130) Inject RelayClient from experiment.ts when RTBT rules are present: non-blocking beginRelaySync after Pass 1 applyVariants, Pass 2 re-apply when behaviors change (storage sync activates with #334). Co-authored-by: Cursor --- .../behavioral-targeting-manager.ts | 58 +++++++++ packages/experiment-tag/src/experiment.ts | 60 +++++++++ packages/experiment-tag/src/types.ts | 2 + ...behavioral-targeting-manager-relay.test.ts | 78 ++++++++++++ .../test/experiment-relay-iframe.test.ts | 114 ++++++++++++++++++ 5 files changed, 312 insertions(+) create mode 100644 packages/experiment-tag/test/behavioral-targeting/behavioral-targeting-manager-relay.test.ts create mode 100644 packages/experiment-tag/test/experiment-relay-iframe.test.ts diff --git a/packages/experiment-tag/src/behavioral-targeting/behavioral-targeting-manager.ts b/packages/experiment-tag/src/behavioral-targeting/behavioral-targeting-manager.ts index d709486d..f3262032 100644 --- a/packages/experiment-tag/src/behavioral-targeting/behavioral-targeting-manager.ts +++ b/packages/experiment-tag/src/behavioral-targeting/behavioral-targeting-manager.ts @@ -2,6 +2,7 @@ import { BehavioralTargetingRules } from '../types'; import { BehavioralTargetingEvaluator } from './evaluator'; import { EventStorageManager } from './event-storage'; +import { RelayClient } from './relay-client'; import { SessionManager } from './session-manager'; import { BehavioralTargeting } from './types'; @@ -51,6 +52,55 @@ export class BehavioralTargetingManager { this.evaluateEvent(eventType); } + /** + * Attach the relay client for cross-subdomain event dual-write (#334). + */ + public setRelayClient(relayClient: RelayClient | null): void { + ( + this.eventStorage as { + setRelayClient?: (client: RelayClient | null) => void; + } + ).setRelayClient?.(relayClient); + } + + /** + * Pass 2: migrate local events to relay if needed, merge relay store, re-evaluate. + * Returns true when relay store was merged (#334). + */ + public async syncFromRelay(): Promise { + const sync = ( + this.eventStorage as { syncFromRelay?: () => Promise } + ).syncFromRelay; + if (!sync) { + return false; + } + const synced = await sync.call(this.eventStorage); + if (synced) { + this.evaluateAll(); + } + return synced; + } + + /** + * WEB-130: inject relay iframe (non-blocking init) and run Pass 2 sync when + * event-storage relay hooks are present (#334). + * + * @returns true when matched behaviors changed after relay sync + */ + public async beginRelaySync(relayClient: RelayClient): Promise { + const behaviorsBefore = this.serializeMatchedBehaviors(); + await relayClient.init(); + if (!relayClient.relayAvailable) { + return false; + } + this.setRelayClient(relayClient); + const synced = await this.syncFromRelay(); + if (!synced) { + return false; + } + return behaviorsBefore !== this.serializeMatchedBehaviors(); + } + /** * Check if a flag has behavioral targeting rules. * @param flagKey The flag key to check @@ -175,6 +225,14 @@ export class BehavioralTargetingManager { * @param flagKey The flag key to evaluate * @param behaviorId The specific behavior ID to evaluate */ + private serializeMatchedBehaviors(): string { + const snapshot: Record = {}; + for (const [flagKey, behaviorIds] of this.matchedBehaviors.entries()) { + snapshot[flagKey] = [...behaviorIds].sort(); + } + return JSON.stringify(snapshot); + } + private evaluateBehaviorId(flagKey: string, behaviorId: string): void { const rulesByIds = this.rules[flagKey]; if (!rulesByIds || !rulesByIds[behaviorId]) { diff --git a/packages/experiment-tag/src/experiment.ts b/packages/experiment-tag/src/experiment.ts index 042e5160..30fdbf48 100644 --- a/packages/experiment-tag/src/experiment.ts +++ b/packages/experiment-tag/src/experiment.ts @@ -24,6 +24,8 @@ import * as domMutatorExports from 'dom-mutator'; import type { MutationController } from 'dom-mutator/dist/types'; import { BehavioralTargetingManager } from './behavioral-targeting'; +import { getRelayUrl, RelayClient } from './behavioral-targeting/relay-client'; +import { WEB_EXP_ID_V2_PATTERN } from './behavioral-targeting/relay-protocol'; import { showPreviewModeModal } from './preview/preview'; import { MessageBus } from './subscriptions/message-bus'; import { @@ -138,6 +140,7 @@ export class DefaultWebExperimentClient implements WebExperimentClient { public readonly behavioralTargetingManager: | BehavioralTargetingManager | undefined; + private relayClient: RelayClient | null = null; private subscriptionManager: SubscriptionManager | undefined; private isVisualEditorMode = false; private isDebugActive = false; @@ -466,6 +469,7 @@ export class DefaultWebExperimentClient implements WebExperimentClient { Object.keys(this.previewFlags).includes(key), ) ) { + this.scheduleRelaySync(enrichedUser); this.isRunning = true; flushEventBuffer(this); return; @@ -474,6 +478,7 @@ export class DefaultWebExperimentClient implements WebExperimentClient { await this.fetchRemoteFlags(); // apply remote variants - if fetch is unsuccessful, fallback order: 1. localStorage flags, 2. initial flags await this.applyVariants({ flagKeys: this.remoteFlagKeys }); + this.scheduleRelaySync(enrichedUser); this.isRunning = true; flushEventBuffer(this); } @@ -710,6 +715,61 @@ export class DefaultWebExperimentClient implements WebExperimentClient { } } + /** + * Non-blocking WEB-130 relay iframe init + Pass 2 sync (requires #334 storage hooks). + */ + private scheduleRelaySync(user: WebExperimentUser): void { + if (!this.behavioralTargetingManager || this.isVisualEditorMode) { + return; + } + + const webExpIdV2 = user.web_exp_id_v2 ?? user.web_exp_id; + if (!webExpIdV2 || !WEB_EXP_ID_V2_PATTERN.test(webExpIdV2)) { + return; + } + + this.relayClient?.destroy(); + this.relayClient = new RelayClient( + this.apiKey, + webExpIdV2, + getRelayUrl(this.apiKey), + ); + + void this.behavioralTargetingManager + .beginRelaySync(this.relayClient) + .then((behaviorsChanged) => this.handleRelayPass2(behaviorsChanged)) + .catch(() => { + // relay failure is local-only fallback + }); + } + + /** + * Pass 2: re-apply variants when relay sync changes matched behaviors. + */ + private async handleRelayPass2(behaviorsChanged: boolean): Promise { + if (!behaviorsChanged || !this.behavioralTargetingManager) { + return; + } + + this.updateUserWithBehaviors(); + + const flagKeys = Object.keys(this.behavioralTargetingRules); + const localKeys = flagKeys.filter((key) => + this.localFlagKeys.includes(key), + ); + const remoteKeys = flagKeys.filter((key) => + this.remoteFlagKeys.includes(key), + ); + + if (localKeys.length > 0) { + await this.applyVariants({ flagKeys: localKeys }); + } + if (remoteKeys.length > 0) { + await this.fetchRemoteFlags(); + await this.applyVariants({ flagKeys: remoteKeys }); + } + } + /** * Update the user with matched behavioral targeting IDs. * Sets the user property `behavioral_targeting` to an array of all matched behavior IDs. diff --git a/packages/experiment-tag/src/types.ts b/packages/experiment-tag/src/types.ts index 9c55ccc5..001565bb 100644 --- a/packages/experiment-tag/src/types.ts +++ b/packages/experiment-tag/src/types.ts @@ -173,6 +173,8 @@ export interface WebExperimentClient { export type WebExperimentUser = { web_exp_id?: string; + /** Cross-subdomain visitor ID (#332); falls back to web_exp_id for relay wiring. */ + web_exp_id_v2?: string; } & ExperimentUser; export type InitConfigs = { diff --git a/packages/experiment-tag/test/behavioral-targeting/behavioral-targeting-manager-relay.test.ts b/packages/experiment-tag/test/behavioral-targeting/behavioral-targeting-manager-relay.test.ts new file mode 100644 index 00000000..c823803f --- /dev/null +++ b/packages/experiment-tag/test/behavioral-targeting/behavioral-targeting-manager-relay.test.ts @@ -0,0 +1,78 @@ +import { EvaluationOperator } from '@amplitude/experiment-core'; +import { BehavioralTargetingManager } from 'src/behavioral-targeting/behavioral-targeting-manager'; +import { + RelayClient, + getRelayUrl, +} from 'src/behavioral-targeting/relay-client'; +import { RELAY_READY_MESSAGE } from 'src/behavioral-targeting/relay-protocol'; + +const API_KEY = 'test-api-key'; +const WEB_EXP_ID_V2 = 'oeu1383080393924r0-5047421827912331'; +const RELAY_URL = getRelayUrl(API_KEY); +const RELAY_ORIGIN = 'https://cdn.amplitude.com'; + +describe('BehavioralTargetingManager relay wiring', () => { + let relayClient: RelayClient | null = null; + + beforeEach(() => { + jest.useFakeTimers(); + localStorage.clear(); + sessionStorage.clear(); + document.body.innerHTML = ''; + }); + + afterEach(() => { + relayClient?.destroy(); + relayClient = null; + jest.useRealTimers(); + }); + + const signalRelayReady = () => { + const iframe = document.querySelector('iframe') as HTMLIFrameElement; + const iframeWindow = { postMessage: jest.fn() }; + Object.defineProperty(iframe, 'contentWindow', { + value: iframeWindow, + configurable: true, + }); + window.dispatchEvent( + new MessageEvent('message', { + data: RELAY_READY_MESSAGE, + source: iframeWindow as unknown as MessageEventSource, + origin: RELAY_ORIGIN, + }), + ); + return iframeWindow; + }; + + test('beginRelaySync injects relay iframe and returns false without storage hooks', async () => { + const manager = new BehavioralTargetingManager(API_KEY, { + flag_a: { + behavior_1: [ + [ + { + condition: { + type: 'behavior', + event_type: 'click', + op: EvaluationOperator.GREATER_THAN_EQUALS, + value: 1, + time_type: 'rolling', + time_value: 7, + interval: 'day', + }, + }, + ], + ], + }, + }); + + relayClient = new RelayClient(API_KEY, WEB_EXP_ID_V2, RELAY_URL); + const initPromise = manager.beginRelaySync(relayClient); + await jest.runAllTimersAsync(); + signalRelayReady(); + await initPromise; + + expect(document.querySelector('iframe')).not.toBeNull(); + expect(relayClient.relayAvailable).toBe(true); + expect(await manager.syncFromRelay()).toBe(false); + }); +}); diff --git a/packages/experiment-tag/test/experiment-relay-iframe.test.ts b/packages/experiment-tag/test/experiment-relay-iframe.test.ts new file mode 100644 index 00000000..712c4e00 --- /dev/null +++ b/packages/experiment-tag/test/experiment-relay-iframe.test.ts @@ -0,0 +1,114 @@ +import * as experimentCore from '@amplitude/experiment-core'; +import { EvaluationOperator } from '@amplitude/experiment-core'; +import { ExperimentClient } from '@amplitude/experiment-js-client'; +import { RelayClient } from 'src/behavioral-targeting/relay-client'; +import { DefaultWebExperimentClient } from 'src/experiment'; +import { stringify } from 'ts-jest'; + +import { createPageObject } from './util/create-page-object'; +import { createMockGlobal, setupGlobalObservers } from './util/mocks'; + +const mockRelayInit = jest.fn().mockResolvedValue(undefined); +const mockRelayDestroy = jest.fn(); + +jest.mock('src/behavioral-targeting/relay-client', () => { + const actual = jest.requireActual('src/behavioral-targeting/relay-client'); + return { + ...actual, + RelayClient: jest.fn().mockImplementation(() => ({ + init: mockRelayInit, + destroy: mockRelayDestroy, + relayAvailable: false, + })), + }; +}); + +setupGlobalObservers(); + +describe('DefaultWebExperimentClient relay iframe (WEB-130)', () => { + let apiKey = 0; + const mockGetGlobalScope = jest.spyOn(experimentCore, 'getGlobalScope'); + let mockGlobal: ReturnType; + + beforeEach(() => { + apiKey++; + jest.clearAllMocks(); + jest.spyOn(experimentCore, 'isLocalStorageAvailable').mockReturnValue(true); + mockGlobal = createMockGlobal(); + mockGetGlobalScope.mockReturnValue( + mockGlobal as unknown as typeof globalThis, + ); + jest.spyOn(ExperimentClient.prototype, 'setUser').mockImplementation(); + jest.spyOn(ExperimentClient.prototype, 'all').mockReturnValue({}); + jest + .spyOn(ExperimentClient.prototype, 'fetch') + .mockResolvedValue({} as never); + }); + + test('starts relay sync when behavioral targeting rules are present', async () => { + const key = stringify(apiKey); + const behavioralTargetingRules = { + flag_a: { + behavior_1: [ + [ + { + condition: { + type: 'behavior', + event_type: 'click', + op: EvaluationOperator.GREATER_THAN_EQUALS, + value: 1, + time_type: 'rolling', + time_value: 7, + interval: 'day', + }, + }, + ], + ], + }, + }; + + const client = DefaultWebExperimentClient.getInstance(key, { + initialFlags: JSON.stringify([]), + pageObjects: JSON.stringify({ + flag_a: createPageObject( + 'A', + 'url_change', + undefined, + 'http://test.com', + ), + }), + behavioralTargetingRules: JSON.stringify(behavioralTargetingRules), + }); + + mockGlobal.localStorage.getItem.mockImplementation((storageKey: string) => { + if (storageKey === `EXP_${key.slice(0, 10)}`) { + return JSON.stringify({ + web_exp_id: 'oeu1383080393924r0-5047421827912331', + }); + } + return null; + }); + + await client.start(); + + expect(RelayClient).toHaveBeenCalledWith( + key, + 'oeu1383080393924r0-5047421827912331', + expect.stringContaining('.relay.html'), + ); + expect(mockRelayInit).toHaveBeenCalled(); + }); + + test('skips relay sync when there are no behavioral targeting rules', async () => { + const key = stringify(apiKey); + const client = DefaultWebExperimentClient.getInstance(key, { + initialFlags: JSON.stringify([]), + pageObjects: JSON.stringify({}), + }); + + await client.start(); + + expect(RelayClient).not.toHaveBeenCalled(); + expect(mockRelayInit).not.toHaveBeenCalled(); + }); +}); From 497f859751fa20f51182f48c3c6e8ded2e75c667 Mon Sep 17 00:00:00 2001 From: Tim Yiu <137842098+tyiuhc@users.noreply.github.com> Date: Tue, 16 Jun 2026 15:14:48 -0700 Subject: [PATCH 02/14] fix(experiment-tag): cleanup relay client after failed init (WEB-130) Destroy the RelayClient when relay stays unavailable after init, and log Pass 2 re-apply failures separately from relay sync errors. Co-authored-by: Cursor --- packages/experiment-tag/src/experiment.ts | 14 ++++++++++++-- .../test/experiment-relay-iframe.test.ts | 2 ++ 2 files changed, 14 insertions(+), 2 deletions(-) diff --git a/packages/experiment-tag/src/experiment.ts b/packages/experiment-tag/src/experiment.ts index 30fdbf48..3d45d3fc 100644 --- a/packages/experiment-tag/src/experiment.ts +++ b/packages/experiment-tag/src/experiment.ts @@ -737,9 +737,19 @@ export class DefaultWebExperimentClient implements WebExperimentClient { void this.behavioralTargetingManager .beginRelaySync(this.relayClient) - .then((behaviorsChanged) => this.handleRelayPass2(behaviorsChanged)) + .then((behaviorsChanged) => { + if (!this.relayClient?.relayAvailable) { + this.relayClient?.destroy(); + this.relayClient = null; + return; + } + return this.handleRelayPass2(behaviorsChanged).catch((pass2Error) => { + console.warn('Experiment relay Pass 2 failed:', pass2Error); + }); + }) .catch(() => { - // relay failure is local-only fallback + this.relayClient?.destroy(); + this.relayClient = null; }); } diff --git a/packages/experiment-tag/test/experiment-relay-iframe.test.ts b/packages/experiment-tag/test/experiment-relay-iframe.test.ts index 712c4e00..ce43556e 100644 --- a/packages/experiment-tag/test/experiment-relay-iframe.test.ts +++ b/packages/experiment-tag/test/experiment-relay-iframe.test.ts @@ -97,6 +97,8 @@ describe('DefaultWebExperimentClient relay iframe (WEB-130)', () => { expect.stringContaining('.relay.html'), ); expect(mockRelayInit).toHaveBeenCalled(); + await Promise.resolve(); + expect(mockRelayDestroy).toHaveBeenCalled(); }); test('skips relay sync when there are no behavioral targeting rules', async () => { From 60cf2472e140f188848ad20e5d08aba063d63366 Mon Sep 17 00:00:00 2001 From: Tim Yiu <137842098+tyiuhc@users.noreply.github.com> Date: Tue, 16 Jun 2026 15:19:59 -0700 Subject: [PATCH 03/14] fix(experiment-tag): guard relay callbacks against stale clients (WEB-130) Tie beginRelaySync completion to the RelayClient instance that started it so a replaced client is not destroyed by an older promise. Co-authored-by: Cursor --- packages/experiment-tag/src/experiment.ts | 17 ++++++++++++----- 1 file changed, 12 insertions(+), 5 deletions(-) diff --git a/packages/experiment-tag/src/experiment.ts b/packages/experiment-tag/src/experiment.ts index 3d45d3fc..038a5353 100644 --- a/packages/experiment-tag/src/experiment.ts +++ b/packages/experiment-tag/src/experiment.ts @@ -729,17 +729,21 @@ export class DefaultWebExperimentClient implements WebExperimentClient { } this.relayClient?.destroy(); - this.relayClient = new RelayClient( + const relayClient = new RelayClient( this.apiKey, webExpIdV2, getRelayUrl(this.apiKey), ); + this.relayClient = relayClient; void this.behavioralTargetingManager - .beginRelaySync(this.relayClient) + .beginRelaySync(relayClient) .then((behaviorsChanged) => { - if (!this.relayClient?.relayAvailable) { - this.relayClient?.destroy(); + if (this.relayClient !== relayClient) { + return; + } + if (!relayClient.relayAvailable) { + relayClient.destroy(); this.relayClient = null; return; } @@ -748,7 +752,10 @@ export class DefaultWebExperimentClient implements WebExperimentClient { }); }) .catch(() => { - this.relayClient?.destroy(); + if (this.relayClient !== relayClient) { + return; + } + relayClient.destroy(); this.relayClient = null; }); } From c13dbe15aa591367b11c6b7b81986d51cbb3e39b Mon Sep 17 00:00:00 2001 From: Tim Yiu <137842098+tyiuhc@users.noreply.github.com> Date: Tue, 16 Jun 2026 15:48:08 -0700 Subject: [PATCH 04/14] chore(experiment-tag): drop web_exp_id_v2 pattern gate in relay sync Relay sync only requires a non-empty web_exp_id_v2 or web_exp_id. Co-authored-by: Cursor --- packages/experiment-tag/src/experiment.ts | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/packages/experiment-tag/src/experiment.ts b/packages/experiment-tag/src/experiment.ts index 038a5353..a21aa8ee 100644 --- a/packages/experiment-tag/src/experiment.ts +++ b/packages/experiment-tag/src/experiment.ts @@ -25,7 +25,6 @@ import type { MutationController } from 'dom-mutator/dist/types'; import { BehavioralTargetingManager } from './behavioral-targeting'; import { getRelayUrl, RelayClient } from './behavioral-targeting/relay-client'; -import { WEB_EXP_ID_V2_PATTERN } from './behavioral-targeting/relay-protocol'; import { showPreviewModeModal } from './preview/preview'; import { MessageBus } from './subscriptions/message-bus'; import { @@ -724,7 +723,7 @@ export class DefaultWebExperimentClient implements WebExperimentClient { } const webExpIdV2 = user.web_exp_id_v2 ?? user.web_exp_id; - if (!webExpIdV2 || !WEB_EXP_ID_V2_PATTERN.test(webExpIdV2)) { + if (!webExpIdV2) { return; } From e59bc9951efb0b824fe6d2a34b6b86695a01bf8d Mon Sep 17 00:00:00 2001 From: Tim Yiu <137842098+tyiuhc@users.noreply.github.com> Date: Tue, 16 Jun 2026 15:58:07 -0700 Subject: [PATCH 05/14] chore(experiment-tag): remove ticket refs from relay comments Co-authored-by: Cursor --- .../behavioral-targeting/behavioral-targeting-manager.ts | 7 +++---- packages/experiment-tag/src/experiment.ts | 2 +- packages/experiment-tag/src/types.ts | 2 +- .../experiment-tag/test/experiment-relay-iframe.test.ts | 2 +- 4 files changed, 6 insertions(+), 7 deletions(-) diff --git a/packages/experiment-tag/src/behavioral-targeting/behavioral-targeting-manager.ts b/packages/experiment-tag/src/behavioral-targeting/behavioral-targeting-manager.ts index f3262032..69faaea9 100644 --- a/packages/experiment-tag/src/behavioral-targeting/behavioral-targeting-manager.ts +++ b/packages/experiment-tag/src/behavioral-targeting/behavioral-targeting-manager.ts @@ -53,7 +53,7 @@ export class BehavioralTargetingManager { } /** - * Attach the relay client for cross-subdomain event dual-write (#334). + * Attach the relay client for cross-subdomain event dual-write. */ public setRelayClient(relayClient: RelayClient | null): void { ( @@ -65,7 +65,7 @@ export class BehavioralTargetingManager { /** * Pass 2: migrate local events to relay if needed, merge relay store, re-evaluate. - * Returns true when relay store was merged (#334). + * Returns true when relay store was merged. */ public async syncFromRelay(): Promise { const sync = ( @@ -82,8 +82,7 @@ export class BehavioralTargetingManager { } /** - * WEB-130: inject relay iframe (non-blocking init) and run Pass 2 sync when - * event-storage relay hooks are present (#334). + * Inject relay iframe (non-blocking init) and run Pass 2 sync. * * @returns true when matched behaviors changed after relay sync */ diff --git a/packages/experiment-tag/src/experiment.ts b/packages/experiment-tag/src/experiment.ts index a21aa8ee..3938f98e 100644 --- a/packages/experiment-tag/src/experiment.ts +++ b/packages/experiment-tag/src/experiment.ts @@ -715,7 +715,7 @@ export class DefaultWebExperimentClient implements WebExperimentClient { } /** - * Non-blocking WEB-130 relay iframe init + Pass 2 sync (requires #334 storage hooks). + * Non-blocking relay iframe init + Pass 2 sync. */ private scheduleRelaySync(user: WebExperimentUser): void { if (!this.behavioralTargetingManager || this.isVisualEditorMode) { diff --git a/packages/experiment-tag/src/types.ts b/packages/experiment-tag/src/types.ts index 001565bb..e8af8f01 100644 --- a/packages/experiment-tag/src/types.ts +++ b/packages/experiment-tag/src/types.ts @@ -173,7 +173,7 @@ export interface WebExperimentClient { export type WebExperimentUser = { web_exp_id?: string; - /** Cross-subdomain visitor ID (#332); falls back to web_exp_id for relay wiring. */ + /** Cross-subdomain visitor ID; falls back to web_exp_id for relay wiring. */ web_exp_id_v2?: string; } & ExperimentUser; diff --git a/packages/experiment-tag/test/experiment-relay-iframe.test.ts b/packages/experiment-tag/test/experiment-relay-iframe.test.ts index ce43556e..6238ebb2 100644 --- a/packages/experiment-tag/test/experiment-relay-iframe.test.ts +++ b/packages/experiment-tag/test/experiment-relay-iframe.test.ts @@ -25,7 +25,7 @@ jest.mock('src/behavioral-targeting/relay-client', () => { setupGlobalObservers(); -describe('DefaultWebExperimentClient relay iframe (WEB-130)', () => { +describe('DefaultWebExperimentClient relay iframe', () => { let apiKey = 0; const mockGetGlobalScope = jest.spyOn(experimentCore, 'getGlobalScope'); let mockGlobal: ReturnType; From 719cb4df6218c3961a9d54420b1b3fd43922fe02 Mon Sep 17 00:00:00 2001 From: Tim Yiu <137842098+tyiuhc@users.noreply.github.com> Date: Tue, 16 Jun 2026 16:51:30 -0700 Subject: [PATCH 06/14] fix(experiment-tag): address Bugbot late relay ready and sync timing Add waitForAvailable on RelayClient and attach storage before init so Pass 2 sync runs when the relay iframe becomes ready after the init timeout. Co-authored-by: Cursor --- .../behavioral-targeting-manager.ts | 7 +- .../src/behavioral-targeting/relay-client.ts | 67 +++++++++++++++++-- ...behavioral-targeting-manager-relay.test.ts | 18 ++++- .../behavioral-targeting/relay-client.test.ts | 65 ++++++++++++++++++ .../test/experiment-relay-iframe.test.ts | 5 +- 5 files changed, 152 insertions(+), 10 deletions(-) diff --git a/packages/experiment-tag/src/behavioral-targeting/behavioral-targeting-manager.ts b/packages/experiment-tag/src/behavioral-targeting/behavioral-targeting-manager.ts index 69faaea9..bdcad4a6 100644 --- a/packages/experiment-tag/src/behavioral-targeting/behavioral-targeting-manager.ts +++ b/packages/experiment-tag/src/behavioral-targeting/behavioral-targeting-manager.ts @@ -88,13 +88,18 @@ export class BehavioralTargetingManager { */ public async beginRelaySync(relayClient: RelayClient): Promise { const behaviorsBefore = this.serializeMatchedBehaviors(); + this.setRelayClient(relayClient); await relayClient.init(); if (!relayClient.relayAvailable) { + await relayClient.waitForAvailable(); + } + if (!relayClient.relayAvailable) { + this.setRelayClient(null); return false; } - this.setRelayClient(relayClient); const synced = await this.syncFromRelay(); if (!synced) { + this.setRelayClient(null); return false; } return behaviorsBefore !== this.serializeMatchedBehaviors(); diff --git a/packages/experiment-tag/src/behavioral-targeting/relay-client.ts b/packages/experiment-tag/src/behavioral-targeting/relay-client.ts index 4702b0d2..01f0d586 100644 --- a/packages/experiment-tag/src/behavioral-targeting/relay-client.ts +++ b/packages/experiment-tag/src/behavioral-targeting/relay-client.ts @@ -49,6 +49,7 @@ export class RelayClient { private initResolve: (() => void) | null = null; private cancelBodyReadyPoll: (() => void) | null = null; private destroyed = false; + private readonly availableWaiters: Array<() => void> = []; constructor( private readonly apiKey: string, @@ -75,6 +76,46 @@ export class RelayClient { return this.available; } + private notifyAvailable(): void { + const waiters = [...this.availableWaiters]; + this.availableWaiters.length = 0; + for (const waiter of waiters) { + waiter(); + } + } + + /** + * Resolves when the relay becomes available, or after timeout. + * Use after init() when the init timer may have fired before RELAY_READY. + */ + waitForAvailable(timeoutMs = RELAY_RPC_TIMEOUT_MS): Promise { + if (this.destroyed) { + return Promise.resolve(false); + } + if (this.available) { + return Promise.resolve(true); + } + + return new Promise((resolve) => { + let settled = false; + const finish = () => { + if (settled) { + return; + } + settled = true; + window.clearTimeout(timeoutId); + const idx = this.availableWaiters.indexOf(onAvailable); + if (idx !== -1) { + this.availableWaiters.splice(idx, 1); + } + resolve(this.available && !this.destroyed); + }; + const onAvailable = () => finish(); + this.availableWaiters.push(onAvailable); + const timeoutId = window.setTimeout(() => finish(), timeoutMs); + }); + } + async init(): Promise { if (this.initPromise) { return this.initPromise; @@ -131,6 +172,7 @@ export class RelayClient { this.iframeWindow = iframe.contentWindow; this.available = true; this.flush(); + this.notifyAvailable(); finishInit(); return; } @@ -189,15 +231,27 @@ export class RelayClient { if (this.destroyed) { return; } + + // Queue until async write confirms — flush() can resend in-flight events on unload. + this.pendingWrites.push(event); + if (!this.available || !this.iframeWindow) { - this.pendingWrites.push(event); return; } - void this.sendRequest( - this.createRelayRequest('WRITE_EVENT', { event }), - ).catch(() => { - // fire-and-forget - }); + + void this.sendRequest(this.createRelayRequest('WRITE_EVENT', { event })) + .then((response) => { + if (!response.ok) { + return; + } + const idx = this.pendingWrites.indexOf(event); + if (idx !== -1) { + this.pendingWrites.splice(idx, 1); + } + }) + .catch(() => { + // Keep in pendingWrites for flush() + }); } flush(): void { @@ -256,6 +310,7 @@ export class RelayClient { pending.reject(new Error('relay destroyed')); } this.pendingRequests.clear(); + this.availableWaiters.length = 0; this.iframe?.remove(); this.iframe = null; this.iframeWindow = null; diff --git a/packages/experiment-tag/test/behavioral-targeting/behavioral-targeting-manager-relay.test.ts b/packages/experiment-tag/test/behavioral-targeting/behavioral-targeting-manager-relay.test.ts index c823803f..e1b2719b 100644 --- a/packages/experiment-tag/test/behavioral-targeting/behavioral-targeting-manager-relay.test.ts +++ b/packages/experiment-tag/test/behavioral-targeting/behavioral-targeting-manager-relay.test.ts @@ -44,7 +44,7 @@ describe('BehavioralTargetingManager relay wiring', () => { return iframeWindow; }; - test('beginRelaySync injects relay iframe and returns false without storage hooks', async () => { + test('beginRelaySync injects relay iframe and completes Pass 2 sync attempt', async () => { const manager = new BehavioralTargetingManager(API_KEY, { flag_a: { behavior_1: [ @@ -73,6 +73,20 @@ describe('BehavioralTargetingManager relay wiring', () => { expect(document.querySelector('iframe')).not.toBeNull(); expect(relayClient.relayAvailable).toBe(true); - expect(await manager.syncFromRelay()).toBe(false); + }); + + test('beginRelaySync waits for late relay ready after init timeout', async () => { + const manager = new BehavioralTargetingManager(API_KEY, {}); + + relayClient = new RelayClient(API_KEY, WEB_EXP_ID_V2, RELAY_URL); + const syncPromise = manager.beginRelaySync(relayClient); + + await jest.runAllTimersAsync(); + expect(relayClient.relayAvailable).toBe(false); + + signalRelayReady(); + await syncPromise; + + expect(relayClient.relayAvailable).toBe(true); }); }); diff --git a/packages/experiment-tag/test/behavioral-targeting/relay-client.test.ts b/packages/experiment-tag/test/behavioral-targeting/relay-client.test.ts index 9dfe4908..f5db86f7 100644 --- a/packages/experiment-tag/test/behavioral-targeting/relay-client.test.ts +++ b/packages/experiment-tag/test/behavioral-targeting/relay-client.test.ts @@ -202,6 +202,7 @@ describe('RelayClient', () => { await initReady(client, iframeWindow); client.writeEvent(sampleEvent(1, { page: 'home' })); + await Promise.resolve(); client.flush(); const writeCalls = postMessage.mock.calls.filter( @@ -210,6 +211,58 @@ describe('RelayClient', () => { expect(writeCalls).toHaveLength(1); }); + test('flush includes in-flight writes not yet confirmed', async () => { + const { client, iframeWindow, postMessage } = setupClient(); + await initReady(client, iframeWindow); + + client.writeEvent(sampleEvent(1, { page: 'home' })); + client.flush(); + + const writeCalls = postMessage.mock.calls.filter( + ([payload]) => payload.type === 'WRITE_EVENT', + ); + expect(writeCalls).toHaveLength(2); + }); + + test('keeps failed write in pending queue for flush retry', async () => { + const postMessage = jest.fn( + (payload: { requestId?: string; type?: string }) => { + if (payload.requestId && payload.type === 'WRITE_EVENT') { + window.dispatchEvent( + new MessageEvent('message', { + data: { + requestId: payload.requestId, + ok: false, + error: 'write rejected', + }, + source: iframeWindow as unknown as MessageEventSource, + origin: RELAY_ORIGIN, + }), + ); + } + }, + ); + const iframeWindow = { postMessage }; + const client = new RelayClient(API_KEY, WEB_EXP_ID_V2, RELAY_URL); + clients.push(client); + await initReady(client, iframeWindow); + + client.writeEvent(sampleEvent(1, { page: 'home' })); + await Promise.resolve(); + + const writeCallsBeforeFlush = postMessage.mock.calls.filter( + ([payload]) => payload.type === 'WRITE_EVENT', + ); + expect(writeCallsBeforeFlush).toHaveLength(1); + + client.flush(); + + const writeCalls = postMessage.mock.calls.filter( + ([payload]) => payload.type === 'WRITE_EVENT', + ); + expect(writeCalls).toHaveLength(2); + }); + test('concurrent init creates only one iframe', async () => { const { client, iframeWindow } = setupClient(); const first = client.init(); @@ -233,6 +286,18 @@ describe('RelayClient', () => { expect(client.relayAvailable).toBe(true); }); + test('waitForAvailable resolves after late ready', async () => { + const { client, iframeWindow } = setupClient(); + const initPromise = client.init(); + + jest.advanceTimersByTime(RELAY_RPC_TIMEOUT_MS + 1); + await initPromise; + + const waitPromise = client.waitForAvailable(); + signalRelayReady(iframeWindow); + await expect(waitPromise).resolves.toBe(true); + }); + test('destroy during init allows re-init on same instance', async () => { const { client, iframeWindow } = setupClient(); const initPromise = client.init(); diff --git a/packages/experiment-tag/test/experiment-relay-iframe.test.ts b/packages/experiment-tag/test/experiment-relay-iframe.test.ts index 6238ebb2..52a47720 100644 --- a/packages/experiment-tag/test/experiment-relay-iframe.test.ts +++ b/packages/experiment-tag/test/experiment-relay-iframe.test.ts @@ -10,6 +10,7 @@ import { createMockGlobal, setupGlobalObservers } from './util/mocks'; const mockRelayInit = jest.fn().mockResolvedValue(undefined); const mockRelayDestroy = jest.fn(); +const mockRelayWaitForAvailable = jest.fn().mockResolvedValue(false); jest.mock('src/behavioral-targeting/relay-client', () => { const actual = jest.requireActual('src/behavioral-targeting/relay-client'); @@ -19,6 +20,7 @@ jest.mock('src/behavioral-targeting/relay-client', () => { init: mockRelayInit, destroy: mockRelayDestroy, relayAvailable: false, + waitForAvailable: mockRelayWaitForAvailable, })), }; }); @@ -90,6 +92,7 @@ describe('DefaultWebExperimentClient relay iframe', () => { }); await client.start(); + await new Promise((resolve) => setTimeout(resolve, 0)); expect(RelayClient).toHaveBeenCalledWith( key, @@ -97,7 +100,7 @@ describe('DefaultWebExperimentClient relay iframe', () => { expect.stringContaining('.relay.html'), ); expect(mockRelayInit).toHaveBeenCalled(); - await Promise.resolve(); + expect(mockRelayWaitForAvailable).toHaveBeenCalled(); expect(mockRelayDestroy).toHaveBeenCalled(); }); From c06012e55faa0484f7244a7cc97917e5edc616e3 Mon Sep 17 00:00:00 2001 From: Tim Yiu <137842098+tyiuhc@users.noreply.github.com> Date: Wed, 17 Jun 2026 10:44:39 -0700 Subject: [PATCH 07/14] fix(experiment-tag): address Bugbot relay flush and sync retention Keep pending writes until RPC confirms and retain relay client on sync failure. Co-authored-by: Cursor --- .../behavioral-targeting-manager.ts | 15 ++-------- .../src/behavioral-targeting/relay-client.ts | 29 ++++++++++--------- 2 files changed, 17 insertions(+), 27 deletions(-) diff --git a/packages/experiment-tag/src/behavioral-targeting/behavioral-targeting-manager.ts b/packages/experiment-tag/src/behavioral-targeting/behavioral-targeting-manager.ts index bdcad4a6..07dcd091 100644 --- a/packages/experiment-tag/src/behavioral-targeting/behavioral-targeting-manager.ts +++ b/packages/experiment-tag/src/behavioral-targeting/behavioral-targeting-manager.ts @@ -56,11 +56,7 @@ export class BehavioralTargetingManager { * Attach the relay client for cross-subdomain event dual-write. */ public setRelayClient(relayClient: RelayClient | null): void { - ( - this.eventStorage as { - setRelayClient?: (client: RelayClient | null) => void; - } - ).setRelayClient?.(relayClient); + this.eventStorage.setRelayClient(relayClient); } /** @@ -68,13 +64,7 @@ export class BehavioralTargetingManager { * Returns true when relay store was merged. */ public async syncFromRelay(): Promise { - const sync = ( - this.eventStorage as { syncFromRelay?: () => Promise } - ).syncFromRelay; - if (!sync) { - return false; - } - const synced = await sync.call(this.eventStorage); + const synced = await this.eventStorage.syncFromRelay(); if (synced) { this.evaluateAll(); } @@ -99,7 +89,6 @@ export class BehavioralTargetingManager { } const synced = await this.syncFromRelay(); if (!synced) { - this.setRelayClient(null); return false; } return behaviorsBefore !== this.serializeMatchedBehaviors(); diff --git a/packages/experiment-tag/src/behavioral-targeting/relay-client.ts b/packages/experiment-tag/src/behavioral-targeting/relay-client.ts index 01f0d586..6461977f 100644 --- a/packages/experiment-tag/src/behavioral-targeting/relay-client.ts +++ b/packages/experiment-tag/src/behavioral-targeting/relay-client.ts @@ -234,23 +234,29 @@ export class RelayClient { // Queue until async write confirms — flush() can resend in-flight events on unload. this.pendingWrites.push(event); + this.sendPendingWrite(event); + } + + private removeConfirmedWrite(event: RelayEventRecord): void { + const idx = this.pendingWrites.indexOf(event); + if (idx !== -1) { + this.pendingWrites.splice(idx, 1); + } + } + private sendPendingWrite(event: RelayEventRecord): void { if (!this.available || !this.iframeWindow) { return; } void this.sendRequest(this.createRelayRequest('WRITE_EVENT', { event })) .then((response) => { - if (!response.ok) { - return; - } - const idx = this.pendingWrites.indexOf(event); - if (idx !== -1) { - this.pendingWrites.splice(idx, 1); + if (response.ok) { + this.removeConfirmedWrite(event); } }) .catch(() => { - // Keep in pendingWrites for flush() + // Keep in pendingWrites for a later flush() }); } @@ -258,13 +264,8 @@ export class RelayClient { if (!this.available || !this.iframeWindow) { return; } - const writes = [...this.pendingWrites]; - this.pendingWrites = []; - for (const event of writes) { - this.iframeWindow.postMessage( - this.createRelayRequest('WRITE_EVENT', { event }), - this.relayOrigin, - ); + for (const event of [...this.pendingWrites]) { + this.sendPendingWrite(event); } } From 9c00bef05261b156b1f70b0e3eed82531682cbab Mon Sep 17 00:00:00 2001 From: Tim Yiu <137842098+tyiuhc@users.noreply.github.com> Date: Wed, 17 Jun 2026 10:55:35 -0700 Subject: [PATCH 08/14] refactor(experiment-tag): simplify relay sync lifecycle Typed RelaySyncResult, defer storage attachment until after init/sync, centralize teardownRelay, dedupe pending writes, and add event-storage relay stubs until the storage sync PR lands. Co-authored-by: Cursor --- .../behavioral-targeting-manager.ts | 26 +++++++++++++------ .../src/behavioral-targeting/event-storage.ts | 16 ++++++++++++ .../src/behavioral-targeting/relay-client.ts | 11 ++++++-- .../behavioral-targeting/relay-sync-result.ts | 6 +++++ packages/experiment-tag/src/experiment.ts | 21 ++++++++++----- .../behavioral-targeting/relay-client.test.ts | 15 +++++++++++ 6 files changed, 78 insertions(+), 17 deletions(-) create mode 100644 packages/experiment-tag/src/behavioral-targeting/relay-sync-result.ts diff --git a/packages/experiment-tag/src/behavioral-targeting/behavioral-targeting-manager.ts b/packages/experiment-tag/src/behavioral-targeting/behavioral-targeting-manager.ts index 07dcd091..90bf2af1 100644 --- a/packages/experiment-tag/src/behavioral-targeting/behavioral-targeting-manager.ts +++ b/packages/experiment-tag/src/behavioral-targeting/behavioral-targeting-manager.ts @@ -3,6 +3,7 @@ import { BehavioralTargetingRules } from '../types'; import { BehavioralTargetingEvaluator } from './evaluator'; import { EventStorageManager } from './event-storage'; import { RelayClient } from './relay-client'; +import { RelaySyncResult } from './relay-sync-result'; import { SessionManager } from './session-manager'; import { BehavioralTargeting } from './types'; @@ -74,24 +75,33 @@ export class BehavioralTargetingManager { /** * Inject relay iframe (non-blocking init) and run Pass 2 sync. * - * @returns true when matched behaviors changed after relay sync + * Relay is attached to storage only after init succeeds so Pass 2 migration + * does not duplicate events dual-written during iframe startup. */ - public async beginRelaySync(relayClient: RelayClient): Promise { + public async beginRelaySync( + relayClient: RelayClient, + ): Promise { const behaviorsBefore = this.serializeMatchedBehaviors(); - this.setRelayClient(relayClient); + await relayClient.init(); if (!relayClient.relayAvailable) { await relayClient.waitForAvailable(); } if (!relayClient.relayAvailable) { - this.setRelayClient(null); - return false; + return { status: 'unavailable' }; } - const synced = await this.syncFromRelay(); + + const synced = await this.eventStorage.syncFromRelay(relayClient); + this.setRelayClient(relayClient); + if (!synced) { - return false; + return { status: 'sync_failed' }; } - return behaviorsBefore !== this.serializeMatchedBehaviors(); + + this.evaluateAll(); + return behaviorsBefore !== this.serializeMatchedBehaviors() + ? { status: 'behaviors_changed' } + : { status: 'unchanged' }; } /** diff --git a/packages/experiment-tag/src/behavioral-targeting/event-storage.ts b/packages/experiment-tag/src/behavioral-targeting/event-storage.ts index a03143a7..baff3383 100644 --- a/packages/experiment-tag/src/behavioral-targeting/event-storage.ts +++ b/packages/experiment-tag/src/behavioral-targeting/event-storage.ts @@ -1,3 +1,4 @@ +import { RelayClient } from './relay-client'; import { SessionManager } from './session-manager'; /** @@ -150,6 +151,21 @@ export class EventStorageManager { this.flushToLocalStorage(); } + /** + * Relay dual-write hook — implemented in the storage sync PR. + */ + setRelayClient(relayClient: RelayClient | null): void { + void relayClient; + } + + /** + * Pass 2 relay merge hook — no-op until storage sync lands. + */ + async syncFromRelay(relayClient?: RelayClient): Promise { + void relayClient; + return true; + } + /** * Gets event count for an event type. */ diff --git a/packages/experiment-tag/src/behavioral-targeting/relay-client.ts b/packages/experiment-tag/src/behavioral-targeting/relay-client.ts index 6461977f..534e2368 100644 --- a/packages/experiment-tag/src/behavioral-targeting/relay-client.ts +++ b/packages/experiment-tag/src/behavioral-targeting/relay-client.ts @@ -232,8 +232,15 @@ export class RelayClient { return; } - // Queue until async write confirms — flush() can resend in-flight events on unload. - this.pendingWrites.push(event); + const alreadyQueued = this.pendingWrites.some( + (queued) => + queued.id === event.id && + queued.event_type === event.event_type && + queued.timestamp === event.timestamp, + ); + if (!alreadyQueued) { + this.pendingWrites.push(event); + } this.sendPendingWrite(event); } diff --git a/packages/experiment-tag/src/behavioral-targeting/relay-sync-result.ts b/packages/experiment-tag/src/behavioral-targeting/relay-sync-result.ts new file mode 100644 index 00000000..b70a465b --- /dev/null +++ b/packages/experiment-tag/src/behavioral-targeting/relay-sync-result.ts @@ -0,0 +1,6 @@ +/** Outcome of non-blocking relay iframe init + Pass 2 merge. */ +export type RelaySyncResult = + | { status: 'behaviors_changed' } + | { status: 'unchanged' } + | { status: 'unavailable' } + | { status: 'sync_failed' }; diff --git a/packages/experiment-tag/src/experiment.ts b/packages/experiment-tag/src/experiment.ts index 3938f98e..49056454 100644 --- a/packages/experiment-tag/src/experiment.ts +++ b/packages/experiment-tag/src/experiment.ts @@ -737,16 +737,18 @@ export class DefaultWebExperimentClient implements WebExperimentClient { void this.behavioralTargetingManager .beginRelaySync(relayClient) - .then((behaviorsChanged) => { + .then((result) => { if (this.relayClient !== relayClient) { return; } - if (!relayClient.relayAvailable) { - relayClient.destroy(); - this.relayClient = null; + if (result.status === 'unavailable') { + this.teardownRelay(relayClient); return; } - return this.handleRelayPass2(behaviorsChanged).catch((pass2Error) => { + if (result.status !== 'behaviors_changed') { + return; + } + return this.handleRelayPass2(true).catch((pass2Error) => { console.warn('Experiment relay Pass 2 failed:', pass2Error); }); }) @@ -754,11 +756,16 @@ export class DefaultWebExperimentClient implements WebExperimentClient { if (this.relayClient !== relayClient) { return; } - relayClient.destroy(); - this.relayClient = null; + this.teardownRelay(relayClient); }); } + private teardownRelay(relayClient: RelayClient): void { + relayClient.destroy(); + this.relayClient = null; + this.behavioralTargetingManager?.setRelayClient(null); + } + /** * Pass 2: re-apply variants when relay sync changes matched behaviors. */ diff --git a/packages/experiment-tag/test/behavioral-targeting/relay-client.test.ts b/packages/experiment-tag/test/behavioral-targeting/relay-client.test.ts index f5db86f7..935f328a 100644 --- a/packages/experiment-tag/test/behavioral-targeting/relay-client.test.ts +++ b/packages/experiment-tag/test/behavioral-targeting/relay-client.test.ts @@ -197,6 +197,21 @@ describe('RelayClient', () => { ); }); + test('does not enqueue duplicate pending writes for the same event', async () => { + const { client, iframeWindow, postMessage } = setupClient(); + await initReady(client, iframeWindow); + + const event = sampleEvent(1, { page: 'home' }); + client.writeEvent(event); + client.writeEvent(event); + client.flush(); + + const writeCalls = postMessage.mock.calls.filter( + ([payload]) => payload.type === 'WRITE_EVENT', + ); + expect(writeCalls).toHaveLength(3); + }); + test('does not double-send writes when relay is available', async () => { const { client, iframeWindow, postMessage } = setupClient(); await initReady(client, iframeWindow); From 6794a740c8d9c8b6edbfbbcb9a7228d959d2823d Mon Sep 17 00:00:00 2001 From: Tim Yiu <137842098+tyiuhc@users.noreply.github.com> Date: Wed, 17 Jun 2026 11:37:30 -0700 Subject: [PATCH 09/14] fix(experiment-tag): address Bugbot relay write and client replacement Match pending write removal by event id, settle waitForAvailable on destroy, and clear storage when replacing relay via teardownRelay. Co-authored-by: Cursor --- .../src/behavioral-targeting/relay-client.ts | 21 ++++-- packages/experiment-tag/src/experiment.ts | 4 +- .../behavioral-targeting/relay-client.test.ts | 69 +++++++++++++++++++ 3 files changed, 86 insertions(+), 8 deletions(-) diff --git a/packages/experiment-tag/src/behavioral-targeting/relay-client.ts b/packages/experiment-tag/src/behavioral-targeting/relay-client.ts index 534e2368..2c6cfdea 100644 --- a/packages/experiment-tag/src/behavioral-targeting/relay-client.ts +++ b/packages/experiment-tag/src/behavioral-targeting/relay-client.ts @@ -28,6 +28,14 @@ function createRequestId(): string { return `${Date.now()}-${Math.random().toString(36).slice(2)}`; } +function isSameRelayEvent(a: RelayEventRecord, b: RelayEventRecord): boolean { + return ( + a.id === b.id && + a.event_type === b.event_type && + a.timestamp === b.timestamp + ); +} + export class RelayClient { private iframe: HTMLIFrameElement | null = null; private iframeWindow: Window | null = null; @@ -232,11 +240,8 @@ export class RelayClient { return; } - const alreadyQueued = this.pendingWrites.some( - (queued) => - queued.id === event.id && - queued.event_type === event.event_type && - queued.timestamp === event.timestamp, + const alreadyQueued = this.pendingWrites.some((queued) => + isSameRelayEvent(queued, event), ); if (!alreadyQueued) { this.pendingWrites.push(event); @@ -245,7 +250,9 @@ export class RelayClient { } private removeConfirmedWrite(event: RelayEventRecord): void { - const idx = this.pendingWrites.indexOf(event); + const idx = this.pendingWrites.findIndex((queued) => + isSameRelayEvent(queued, event), + ); if (idx !== -1) { this.pendingWrites.splice(idx, 1); } @@ -318,7 +325,7 @@ export class RelayClient { pending.reject(new Error('relay destroyed')); } this.pendingRequests.clear(); - this.availableWaiters.length = 0; + this.notifyAvailable(); this.iframe?.remove(); this.iframe = null; this.iframeWindow = null; diff --git a/packages/experiment-tag/src/experiment.ts b/packages/experiment-tag/src/experiment.ts index 49056454..108b0bb4 100644 --- a/packages/experiment-tag/src/experiment.ts +++ b/packages/experiment-tag/src/experiment.ts @@ -727,7 +727,9 @@ export class DefaultWebExperimentClient implements WebExperimentClient { return; } - this.relayClient?.destroy(); + if (this.relayClient) { + this.teardownRelay(this.relayClient); + } const relayClient = new RelayClient( this.apiKey, webExpIdV2, diff --git a/packages/experiment-tag/test/behavioral-targeting/relay-client.test.ts b/packages/experiment-tag/test/behavioral-targeting/relay-client.test.ts index 935f328a..e338d4b8 100644 --- a/packages/experiment-tag/test/behavioral-targeting/relay-client.test.ts +++ b/packages/experiment-tag/test/behavioral-targeting/relay-client.test.ts @@ -239,6 +239,61 @@ describe('RelayClient', () => { expect(writeCalls).toHaveLength(2); }); + test('removes queued entry when logically duplicate write confirms', async () => { + const pendingWriteResponses: string[] = []; + const postMessage = jest.fn( + (payload: { requestId?: string; type?: string }) => { + if (payload.requestId && payload.type === 'WRITE_EVENT') { + pendingWriteResponses.push(payload.requestId); + return; + } + if (payload.requestId) { + window.dispatchEvent( + new MessageEvent('message', { + data: { + requestId: payload.requestId, + ok: true, + }, + source: iframeWindow as unknown as MessageEventSource, + origin: RELAY_ORIGIN, + }), + ); + } + }, + ); + const iframeWindow = { postMessage }; + const client = new RelayClient(API_KEY, WEB_EXP_ID_V2, RELAY_URL); + clients.push(client); + await initReady(client, iframeWindow); + postMessage.mockClear(); + pendingWriteResponses.length = 0; + + const event = sampleEvent(7); + client.writeEvent(event); + client.writeEvent({ ...event, properties: { ...event.properties } }); + expect(pendingWriteResponses).toHaveLength(2); + + window.dispatchEvent( + new MessageEvent('message', { + data: { + requestId: pendingWriteResponses[1], + ok: true, + }, + source: iframeWindow as unknown as MessageEventSource, + origin: RELAY_ORIGIN, + }), + ); + await Promise.resolve(); + + postMessage.mockClear(); + client.flush(); + + const writeCalls = postMessage.mock.calls.filter( + ([payload]) => payload.type === 'WRITE_EVENT', + ); + expect(writeCalls).toHaveLength(0); + }); + test('keeps failed write in pending queue for flush retry', async () => { const postMessage = jest.fn( (payload: { requestId?: string; type?: string }) => { @@ -313,6 +368,20 @@ describe('RelayClient', () => { await expect(waitPromise).resolves.toBe(true); }); + test('destroy settles waitForAvailable without waiting for timeout', async () => { + const { client } = setupClient(); + const initPromise = client.init(); + + jest.advanceTimersByTime(RELAY_RPC_TIMEOUT_MS + 1); + await initPromise; + + const waitPromise = client.waitForAvailable(60_000); + client.destroy(); + + await expect(waitPromise).resolves.toBe(false); + expect(jest.getTimerCount()).toBe(0); + }); + test('destroy during init allows re-init on same instance', async () => { const { client, iframeWindow } = setupClient(); const initPromise = client.init(); From f9ee4e3b619956c5a622c0fa7010a3f532e8483c Mon Sep 17 00:00:00 2001 From: Tim Yiu <137842098+tyiuhc@users.noreply.github.com> Date: Wed, 17 Jun 2026 13:42:02 -0700 Subject: [PATCH 10/14] feat(experiment-tag): add cross-subdomain web_exp_id_v2 and first_seen cookies (#332) --- packages/experiment-tag/src/experiment.ts | 51 +++++++++++++++- packages/experiment-tag/src/types.ts | 1 + packages/experiment-tag/src/util/cookie.ts | 30 ++++++++++ .../experiment-tag/test/experiment.test.ts | 60 ++++++++++++++----- 4 files changed, 126 insertions(+), 16 deletions(-) diff --git a/packages/experiment-tag/src/experiment.ts b/packages/experiment-tag/src/experiment.ts index 042e5160..3e7ad79b 100644 --- a/packages/experiment-tag/src/experiment.ts +++ b/packages/experiment-tag/src/experiment.ts @@ -49,7 +49,11 @@ import { import type { AudienceEvaluationDebugInfo, DebugState } from './types/debug'; import { applyAntiFlickerCss, removeAntiFlickerCss } from './util/anti-flicker'; import { enrichUserWithCampaignData } from './util/campaign'; -import { getTopLevelDomain, setMarketingCookie } from './util/cookie'; +import { + getTopLevelDomain, + resolveCrossSubdomainValue, + setMarketingCookie, +} from './util/cookie'; import { cspSafeStyleSheet, type StyleSheetHandle, @@ -391,6 +395,51 @@ export class DefaultWebExperimentClient implements WebExperimentClient { setStorageItem('localStorage', experimentStorageName, user); } + // Resolve web_exp_id_v2 and first_seen as root-domain cookies for + // cross-subdomain identity before getVariants() so anti-flicker and + // local evaluation use the shared first_seen, not a subdomain-local mint. + const rootDomain = await getTopLevelDomain( + this.globalScope.location.hostname, + ); + const crossSubdomainCookieStorage = new CookieStorage({ + ...(rootDomain && { domain: rootDomain }), + sameSite: 'Lax', + expirationDays: 365, + }); + + const webExpIdV2CookieKey = `${experimentStorageName}_web_exp_id_v2`; + // web_exp_id is guaranteed above; seed v2 from it when no cookie or local v2 exists. + user.web_exp_id_v2 = await resolveCrossSubdomainValue( + crossSubdomainCookieStorage, + webExpIdV2CookieKey, + user.web_exp_id_v2 ?? user.web_exp_id, + UUID, + ); + setStorageItem('localStorage', experimentStorageName, user); + + const defaultUserProviderStorageKey = `${experimentStorageName}_DEFAULT_USER_PROVIDER`; + const defaultUserProviderData = + getStorageItem<{ first_seen?: string }>( + 'localStorage', + defaultUserProviderStorageKey, + ) || {}; + const firstSeen = await resolveCrossSubdomainValue( + crossSubdomainCookieStorage, + `${experimentStorageName}_first_seen`, + defaultUserProviderData.first_seen, + () => (Date.now() / 1000).toString(), + ); + if (firstSeen !== defaultUserProviderData.first_seen) { + defaultUserProviderData.first_seen = firstSeen; + setStorageItem( + 'localStorage', + defaultUserProviderStorageKey, + defaultUserProviderData, + ); + } + user.first_seen = firstSeen; + this.experimentClient.setUser(user); + // evaluate variants for page targeting const variants: Variants = this.getVariants(); diff --git a/packages/experiment-tag/src/types.ts b/packages/experiment-tag/src/types.ts index 9c55ccc5..57e11288 100644 --- a/packages/experiment-tag/src/types.ts +++ b/packages/experiment-tag/src/types.ts @@ -173,6 +173,7 @@ export interface WebExperimentClient { export type WebExperimentUser = { web_exp_id?: string; + web_exp_id_v2?: string; } & ExperimentUser; export type InitConfigs = { diff --git a/packages/experiment-tag/src/util/cookie.ts b/packages/experiment-tag/src/util/cookie.ts index d38436f7..a72e48de 100644 --- a/packages/experiment-tag/src/util/cookie.ts +++ b/packages/experiment-tag/src/util/cookie.ts @@ -148,6 +148,36 @@ export async function getTopLevelDomain(hostname: string): Promise { return (cachedDomain = ''); } +/** + * Resolves a cross-subdomain value using cookie storage as the authoritative + * source. Falls back to a provided localStorage value (migration path), then + * generates a new value if neither exists. Attempts to set the cookie when + * missing; cookie I/O failures fall back to localStorage / generateNew. + * Callers are responsible for syncing the returned value back to localStorage. + */ +export async function resolveCrossSubdomainValue( + cookieStorage: CookieStorage, + cookieKey: string, + localStorageValue: string | undefined, + generateNew: () => string, +): Promise { + try { + const cookieValue = await cookieStorage.get(cookieKey); + if (cookieValue) { + return cookieValue; + } + } catch { + // Cookie read blocked; fall through to localStorage / generateNew. + } + const value = localStorageValue ?? generateNew(); + try { + await cookieStorage.set(cookieKey, value); + } catch { + // Cookie write blocked; return value for localStorage-only persistence. + } + return value; +} + export async function setMarketingCookie(apiKey: string, hostname: string) { const domain = await getTopLevelDomain(hostname); const storage = new CookieStorage({ diff --git a/packages/experiment-tag/test/experiment.test.ts b/packages/experiment-tag/test/experiment.test.ts index ccb011cf..ea8e4fd6 100644 --- a/packages/experiment-tag/test/experiment.test.ts +++ b/packages/experiment-tag/test/experiment.test.ts @@ -112,12 +112,47 @@ describe('initializeExperiment', () => { initialFlags: JSON.stringify([]), pageObjects: JSON.stringify({}), }).start(); - expect(ExperimentClient.prototype.setUser).toHaveBeenCalledWith({ - web_exp_id: 'mock', - }); + expect(ExperimentClient.prototype.setUser).toHaveBeenCalledWith( + expect.objectContaining({ + web_exp_id: 'mock', + web_exp_id_v2: expect.any(String), + }), + ); expect(mockGlobal.localStorage.setItem).toHaveBeenCalledWith( 'EXP_' + stringify(apiKey), - JSON.stringify({ web_exp_id: 'mock' }), + JSON.stringify({ web_exp_id: 'mock', web_exp_id_v2: 'mock' }), + ); + }); + + test('seeds web_exp_id_v2 from existing web_exp_id when cookie is missing', async () => { + const key = stringify(apiKey); + const storageKey = 'EXP_' + key; + const cookieKey = storageKey + '_web_exp_id_v2'; + mockGlobal.localStorage.getItem.mockImplementation((name: string) => { + if (name === storageKey) { + return JSON.stringify({ web_exp_id: 'existing-id' }); + } + return null; + }); + + await DefaultWebExperimentClient.getInstance(key, { + initialFlags: JSON.stringify([]), + pageObjects: JSON.stringify({}), + }).start(); + + expect(ExperimentClient.prototype.setUser).toHaveBeenCalledWith( + expect.objectContaining({ + web_exp_id: 'existing-id', + web_exp_id_v2: 'existing-id', + }), + ); + expect(cookieStore[cookieKey]).toBe('existing-id'); + expect(mockGlobal.localStorage.setItem).toHaveBeenCalledWith( + storageKey, + JSON.stringify({ + web_exp_id: 'existing-id', + web_exp_id_v2: 'existing-id', + }), ); }); @@ -1004,7 +1039,7 @@ describe('initializeExperiment', () => { expect(antiFlickerSpy).toHaveBeenCalledTimes(1); }); - test('remote evaluation - fetch fail, test initialFlags variant actions called', () => { + test('remote evaluation - fetch fail, test initialFlags variant actions called', async () => { const initialFlags = [ // remote flag createMutateFlag( @@ -1018,7 +1053,7 @@ describe('initializeExperiment', () => { const mockHttpClient = new MockHttpClient('', 404); - DefaultWebExperimentClient.getInstance( + await DefaultWebExperimentClient.getInstance( stringify(apiKey), { initialFlags: JSON.stringify(initialFlags), @@ -1027,15 +1062,10 @@ describe('initializeExperiment', () => { { httpClient: mockHttpClient, }, - ) - .start() - .then(() => { - // check remote variant actions applied - expect(mockExposure).toHaveBeenCalledTimes(1); - expect(mockExposure).toHaveBeenCalledWith('test'); - }); - // check local flag variant actions called - expect(mockExposure).toHaveBeenCalledTimes(0); + ).start(); + // check remote variant actions applied + expect(mockExposure).toHaveBeenCalledTimes(1); + expect(mockExposure).toHaveBeenCalledWith('test'); expect(antiFlickerSpy).toHaveBeenCalledTimes(1); }); From 324117a5efd5a7c1ceb3df9c7aa2819e613a91de Mon Sep 17 00:00:00 2001 From: Tim Yiu <137842098+tyiuhc@users.noreply.github.com> Date: Wed, 17 Jun 2026 14:17:46 -0700 Subject: [PATCH 11/14] fix(experiment-tag): centralize relay attach ownership in scheduleRelaySync beginRelaySync now only reads/merges relay state and reports the outcome; it no longer attaches the relay for dual-write. The orchestrator attaches via setRelayClient only when sync succeeds and this client is still the active one, so a failed sync or a client superseded by a newer scheduleRelaySync is never attached. Removes the now-dead no-arg syncFromRelay wrapper. Co-authored-by: Cursor --- .../behavioral-targeting-manager.ts | 22 +++++-------------- packages/experiment-tag/src/experiment.ts | 19 +++++++++++----- ...behavioral-targeting-manager-relay.test.ts | 3 +++ 3 files changed, 21 insertions(+), 23 deletions(-) diff --git a/packages/experiment-tag/src/behavioral-targeting/behavioral-targeting-manager.ts b/packages/experiment-tag/src/behavioral-targeting/behavioral-targeting-manager.ts index 90bf2af1..f393cf51 100644 --- a/packages/experiment-tag/src/behavioral-targeting/behavioral-targeting-manager.ts +++ b/packages/experiment-tag/src/behavioral-targeting/behavioral-targeting-manager.ts @@ -61,22 +61,12 @@ export class BehavioralTargetingManager { } /** - * Pass 2: migrate local events to relay if needed, merge relay store, re-evaluate. - * Returns true when relay store was merged. - */ - public async syncFromRelay(): Promise { - const synced = await this.eventStorage.syncFromRelay(); - if (synced) { - this.evaluateAll(); - } - return synced; - } - - /** - * Inject relay iframe (non-blocking init) and run Pass 2 sync. + * Inject relay iframe (non-blocking init) and run the Pass 2 relay merge. * - * Relay is attached to storage only after init succeeds so Pass 2 migration - * does not duplicate events dual-written during iframe startup. + * This only reads/merges relay state and reports the outcome; it never + * attaches the relay for dual-write. The caller owns relay lifecycle and + * decides whether to attach (via {@link setRelayClient}) based on the + * result and whether this client is still the active one. */ public async beginRelaySync( relayClient: RelayClient, @@ -92,8 +82,6 @@ export class BehavioralTargetingManager { } const synced = await this.eventStorage.syncFromRelay(relayClient); - this.setRelayClient(relayClient); - if (!synced) { return { status: 'sync_failed' }; } diff --git a/packages/experiment-tag/src/experiment.ts b/packages/experiment-tag/src/experiment.ts index 108b0bb4..bcfb2b31 100644 --- a/packages/experiment-tag/src/experiment.ts +++ b/packages/experiment-tag/src/experiment.ts @@ -740,19 +740,26 @@ export class DefaultWebExperimentClient implements WebExperimentClient { void this.behavioralTargetingManager .beginRelaySync(relayClient) .then((result) => { + // A newer scheduleRelaySync may have torn this client down and taken + // ownership; never attach or tear down a client we no longer own. if (this.relayClient !== relayClient) { return; } - if (result.status === 'unavailable') { + if ( + result.status === 'unavailable' || + result.status === 'sync_failed' + ) { this.teardownRelay(relayClient); return; } - if (result.status !== 'behaviors_changed') { - return; + // Sync succeeded and this client still owns the relay: attach it for + // ongoing dual-write. + this.behavioralTargetingManager?.setRelayClient(relayClient); + if (result.status === 'behaviors_changed') { + return this.handleRelayPass2(true).catch((pass2Error) => { + console.warn('Experiment relay Pass 2 failed:', pass2Error); + }); } - return this.handleRelayPass2(true).catch((pass2Error) => { - console.warn('Experiment relay Pass 2 failed:', pass2Error); - }); }) .catch(() => { if (this.relayClient !== relayClient) { diff --git a/packages/experiment-tag/test/behavioral-targeting/behavioral-targeting-manager-relay.test.ts b/packages/experiment-tag/test/behavioral-targeting/behavioral-targeting-manager-relay.test.ts index e1b2719b..58899008 100644 --- a/packages/experiment-tag/test/behavioral-targeting/behavioral-targeting-manager-relay.test.ts +++ b/packages/experiment-tag/test/behavioral-targeting/behavioral-targeting-manager-relay.test.ts @@ -65,6 +65,7 @@ describe('BehavioralTargetingManager relay wiring', () => { }, }); + const attachSpy = jest.spyOn(manager, 'setRelayClient'); relayClient = new RelayClient(API_KEY, WEB_EXP_ID_V2, RELAY_URL); const initPromise = manager.beginRelaySync(relayClient); await jest.runAllTimersAsync(); @@ -73,6 +74,8 @@ describe('BehavioralTargetingManager relay wiring', () => { expect(document.querySelector('iframe')).not.toBeNull(); expect(relayClient.relayAvailable).toBe(true); + // Attach is the caller's responsibility, never done inside beginRelaySync. + expect(attachSpy).not.toHaveBeenCalled(); }); test('beginRelaySync waits for late relay ready after init timeout', async () => { From db7006f18eb8c04474395ad53d46771b3f2fc270 Mon Sep 17 00:00:00 2001 From: Tim Yiu <137842098+tyiuhc@users.noreply.github.com> Date: Mon, 22 Jun 2026 14:40:08 -0700 Subject: [PATCH 12/14] feat(experiment-tag): pass server zone when building relay URL scheduleRelaySync now forwards this.config.serverZone to getRelayUrl so EU deployments load the relay iframe from cdn.eu.amplitude.com. Co-authored-by: Cursor --- 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 bcfb2b31..c3114eec 100644 --- a/packages/experiment-tag/src/experiment.ts +++ b/packages/experiment-tag/src/experiment.ts @@ -733,7 +733,7 @@ export class DefaultWebExperimentClient implements WebExperimentClient { const relayClient = new RelayClient( this.apiKey, webExpIdV2, - getRelayUrl(this.apiKey), + getRelayUrl(this.apiKey, this.config.serverZone), ); this.relayClient = relayClient; From 59c9a47ae78137304e5b56520fd6955a103c2ffc Mon Sep 17 00:00:00 2001 From: amplitude-sdk-bot Date: Fri, 26 Jun 2026 21:59:43 +0000 Subject: [PATCH 13/14] chore(release): publish - @amplitude/experiment-tag@0.25.0 --- packages/experiment-tag/CHANGELOG.md | 12 ++++++++++++ packages/experiment-tag/package.json | 2 +- 2 files changed, 13 insertions(+), 1 deletion(-) diff --git a/packages/experiment-tag/CHANGELOG.md b/packages/experiment-tag/CHANGELOG.md index 108165aa..18f1f65f 100644 --- a/packages/experiment-tag/CHANGELOG.md +++ b/packages/experiment-tag/CHANGELOG.md @@ -3,6 +3,18 @@ All notable changes to this project will be documented in this file. See [Conventional Commits](https://conventionalcommits.org) for commit guidelines. +# [0.25.0](https://github.com/amplitude/experiment-js-client/compare/@amplitude/experiment-tag@0.24.0...@amplitude/experiment-tag@0.25.0) (2026-06-26) + + +### Features + +* **experiment-tag:** add cross-subdomain web_exp_id_v2 and first_seen cookies ([#332](https://github.com/amplitude/experiment-js-client/issues/332)) ([f9ee4e3](https://github.com/amplitude/experiment-js-client/commit/f9ee4e3b619956c5a622c0fa7010a3f532e8483c)) +* support cross-subdomain redirect impression tracking ([#238](https://github.com/amplitude/experiment-js-client/issues/238)) ([e4260a3](https://github.com/amplitude/experiment-js-client/commit/e4260a356cae6fa0001879ee03156718dbe5bfc4)) + + + + + # [0.24.0](https://github.com/amplitude/experiment-js-client/compare/@amplitude/experiment-tag@0.23.4...@amplitude/experiment-tag@0.24.0) (2026-06-04) diff --git a/packages/experiment-tag/package.json b/packages/experiment-tag/package.json index 58dad85c..78d117a9 100644 --- a/packages/experiment-tag/package.json +++ b/packages/experiment-tag/package.json @@ -1,6 +1,6 @@ { "name": "@amplitude/experiment-tag", - "version": "0.24.0", + "version": "0.25.0", "description": "Amplitude Experiment Javascript Snippet", "author": "Amplitude", "homepage": "https://github.com/amplitude/experiment-js-client", From 06f949c1b1554ea600d8786e07bdf5b02d040bf1 Mon Sep 17 00:00:00 2001 From: Tim Yiu <137842098+tyiuhc@users.noreply.github.com> Date: Fri, 26 Jun 2026 15:31:42 -0700 Subject: [PATCH 14/14] feat(experiment-tag): pass relayUrl override when building relay URL scheduleRelaySync now forwards this.config.relayUrl to getRelayUrl so a configured relay origin (e.g. a local or staging dynamic-script endpoint) is used for the relay iframe instead of the CDN. Co-authored-by: Cursor --- 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 c3114eec..28c0055f 100644 --- a/packages/experiment-tag/src/experiment.ts +++ b/packages/experiment-tag/src/experiment.ts @@ -733,7 +733,7 @@ export class DefaultWebExperimentClient implements WebExperimentClient { const relayClient = new RelayClient( this.apiKey, webExpIdV2, - getRelayUrl(this.apiKey, this.config.serverZone), + getRelayUrl(this.apiKey, this.config.serverZone, this.config.relayUrl), ); this.relayClient = relayClient;