Skip to content

Commit df23202

Browse files
authored
fix: Web experiment variant action deduplication (#166)
1 parent 25e6367 commit df23202

File tree

2 files changed

+71
-41
lines changed

2 files changed

+71
-41
lines changed

packages/experiment-tag/src/experiment.ts

Lines changed: 39 additions & 25 deletions
Original file line numberDiff line numberDiff line change
@@ -37,6 +37,9 @@ import { WebExperimentClient } from './web-experiment';
3737
export const PAGE_NOT_TARGETED_SEGMENT_NAME = 'Page not targeted';
3838
export const PAGE_IS_EXCLUDED_SEGMENT_NAME = 'Page is excluded';
3939
export const PREVIEW_SEGMENT_NAME = 'Preview';
40+
const MUTATE_ACTION = 'mutate';
41+
const INJECT_ACTION = 'inject';
42+
const REDIRECT_ACTION = 'redirect';
4043

4144
safeGlobal.Experiment = FeatureExperiment;
4245

@@ -47,7 +50,11 @@ export class DefaultWebExperimentClient implements WebExperimentClient {
4750
private readonly globalScope: typeof globalThis;
4851
private readonly experimentClient: ExperimentClient;
4952
private appliedInjections: Set<string> = new Set();
50-
private appliedMutations: Record<string, MutationController[]> = {};
53+
private appliedMutations: {
54+
[experiment: string]: {
55+
[actionType: string]: MutationController[];
56+
};
57+
} = {};
5158
private previousUrl: string | undefined = undefined;
5259
// Cache to track exposure for the current URL, should be cleared on URL change
5360
private urlExposureCache: Record<string, Record<string, string | undefined>> =
@@ -347,11 +354,21 @@ export class DefaultWebExperimentClient implements WebExperimentClient {
347354
if (!flagKeys) {
348355
flagKeys = Object.keys(this.appliedMutations);
349356
}
350-
for (const key of flagKeys) {
351-
this.appliedMutations[key]?.forEach((mutationController) => {
352-
mutationController.revert();
353-
});
354-
delete this.appliedMutations[key];
357+
358+
for (const experiment of flagKeys) {
359+
const actionTypes = this.appliedMutations[experiment];
360+
if (!actionTypes) continue;
361+
362+
for (const actionType in actionTypes) {
363+
actionTypes[actionType].forEach((mutationController) => {
364+
mutationController.revert();
365+
});
366+
delete actionTypes[actionType];
367+
}
368+
// Delete the experiment key if it has no action types left
369+
if (Object.keys(this.appliedMutations[experiment]).length === 0) {
370+
delete this.appliedMutations[experiment];
371+
}
355372
}
356373
}
357374

@@ -429,11 +446,11 @@ export class DefaultWebExperimentClient implements WebExperimentClient {
429446

430447
private handleVariantAction(key: string, variant: Variant) {
431448
for (const action of variant.payload) {
432-
if (action.action === 'redirect') {
449+
if (action.action === REDIRECT_ACTION) {
433450
this.handleRedirect(action, key, variant);
434-
} else if (action.action === 'mutate') {
451+
} else if (action.action === MUTATE_ACTION) {
435452
this.handleMutate(action, key, variant);
436-
} else if (action.action === 'inject') {
453+
} else if (action.action === INJECT_ACTION) {
437454
this.handleInject(action, key, variant);
438455
}
439456
}
@@ -474,23 +491,22 @@ export class DefaultWebExperimentClient implements WebExperimentClient {
474491

475492
private handleMutate(action, key: string, variant: Variant) {
476493
// Check for repeat invocations
477-
if (this.appliedMutations[key]) {
494+
if (this.appliedMutations[key]?.[MUTATE_ACTION]) {
478495
return;
479496
}
480497
const mutations = action.data?.mutations;
498+
if (mutations.length === 0) {
499+
return;
500+
}
481501
const mutationControllers: MutationController[] = [];
482502
mutations.forEach((m) => {
483503
mutationControllers.push(mutate.declarative(m));
484504
});
485-
this.appliedMutations[key] = mutationControllers;
505+
(this.appliedMutations[key] ??= {})[MUTATE_ACTION] = mutationControllers;
486506
this.exposureWithDedupe(key, variant);
487507
}
488508

489509
private handleInject(action, key: string, variant: Variant) {
490-
// Check for repeat invocations
491-
if (this.appliedMutations[key]) {
492-
return;
493-
}
494510
// Validate and transform ID
495511
let id = action.data.id;
496512
if (!id || typeof id !== 'string' || id.length === 0) {
@@ -547,17 +563,15 @@ export class DefaultWebExperimentClient implements WebExperimentClient {
547563
e,
548564
);
549565
}
550-
// Push mutation to remove CSS and any custom state cleanup set in utils.
551-
this.appliedMutations[key] = [
552-
{
553-
revert: () => {
554-
if (utils.remove) utils.remove();
555-
style?.remove();
556-
script?.remove();
557-
this.appliedInjections.delete(id);
558-
},
566+
(this.appliedMutations[key] ??= {})[INJECT_ACTION] ??= [];
567+
this.appliedMutations[key][INJECT_ACTION].push({
568+
revert: () => {
569+
utils.remove?.();
570+
style?.remove();
571+
script?.remove();
572+
this.appliedInjections.delete(id);
559573
},
560-
];
574+
});
561575
this.exposureWithDedupe(key, variant);
562576
}
563577

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

Lines changed: 32 additions & 16 deletions
Original file line numberDiff line numberDiff line change
@@ -439,11 +439,11 @@ describe('initializeExperiment', () => {
439439
test('remote evaluation - fetch successful, antiflicker applied', () => {
440440
const initialFlags = [
441441
// remote flag
442-
createMutateFlag('test-2', 'treatment', [], [], 'remote'),
442+
createMutateFlag('test-2', 'treatment', [{}], [], 'remote'),
443443
// local flag
444-
createMutateFlag('test-1', 'treatment'),
444+
createMutateFlag('test-1', 'treatment', [{}]),
445445
];
446-
const remoteFlags = [createMutateFlag('test-2', 'treatment')];
446+
const remoteFlags = [createMutateFlag('test-2', 'treatment', [{}])];
447447
const mockHttpClient = new MockHttpClient(JSON.stringify(remoteFlags));
448448
DefaultWebExperimentClient.getInstance(
449449
stringify(apiKey),
@@ -467,9 +467,9 @@ describe('initializeExperiment', () => {
467467
test('remote evaluation - fetch fail, locally evaluate remote and local flags success', () => {
468468
const initialFlags = [
469469
// remote flag
470-
createMutateFlag('test-2', 'treatment', [], [], 'remote'),
470+
createMutateFlag('test-2', 'treatment', [{}], [], 'remote'),
471471
// local flag
472-
createMutateFlag('test-1', 'treatment'),
472+
createMutateFlag('test-1', 'treatment', [{}]),
473473
];
474474
const remoteFlags = [createMutateFlag('test-2', 'treatment')];
475475

@@ -496,7 +496,7 @@ describe('initializeExperiment', () => {
496496
test('remote evaluation - fetch fail, test initialFlags variant actions called', () => {
497497
const initialFlags = [
498498
// remote flag
499-
createMutateFlag('test', 'treatment', [], [], 'remote'),
499+
createMutateFlag('test', 'treatment', [{}], [], 'remote'),
500500
];
501501

502502
const mockHttpClient = new MockHttpClient('', 404);
@@ -615,20 +615,28 @@ describe('initializeExperiment', () => {
615615
const apiKey = 'api1';
616616
const storageKey = `amp-exp-$default_instance-web-${apiKey}-flags`;
617617
// Create mock session storage with initial value
618-
const storedFlag = createFlag('test', 'treatment', 'local', false, {
619-
flagVersion: 2,
620-
});
618+
const storedFlag = createMutateFlag(
619+
'test',
620+
'treatment',
621+
[{}],
622+
[],
623+
'local',
624+
false,
625+
{
626+
flagVersion: 2,
627+
},
628+
);
621629
safeGlobal.sessionStorage.setItem(
622630
storageKey,
623631
JSON.stringify({ test: storedFlag }),
624632
);
625633
const initialFlags = [
626-
createMutateFlag('test', 'treatment', [], [], 'remote', false, {
634+
createMutateFlag('test', 'treatment', [{}], [], 'remote', false, {
627635
flagVersion: 3,
628636
}),
629637
];
630638
const remoteFlags = [
631-
createMutateFlag('test', 'treatment', [], [], 'local', false, {
639+
createMutateFlag('test', 'treatment', [{}], [], 'local', false, {
632640
flagVersion: 4,
633641
}),
634642
];
@@ -672,9 +680,17 @@ describe('initializeExperiment', () => {
672680
const apiKey = 'api2';
673681
const storageKey = `amp-exp-$default_instance-web-${apiKey}-flags`;
674682
// Create mock session storage with initial value
675-
const storedFlag = createFlag('test', 'treatment', 'local', false, {
676-
flagVersion: 2,
677-
});
683+
const storedFlag = createMutateFlag(
684+
'test',
685+
'treatment',
686+
[{}],
687+
[],
688+
'local',
689+
false,
690+
{
691+
flagVersion: 2,
692+
},
693+
);
678694
safeGlobal.sessionStorage.setItem(
679695
storageKey,
680696
JSON.stringify({ test: storedFlag }),
@@ -683,12 +699,12 @@ describe('initializeExperiment', () => {
683699
value: sessionStorageMock,
684700
});
685701
const initialFlags = [
686-
createMutateFlag('test', 'treatment', [], [], 'remote', false, {
702+
createMutateFlag('test', 'treatment', [{}], [], 'remote', false, {
687703
flagVersion: 3,
688704
}),
689705
];
690706
const remoteFlags = [
691-
createMutateFlag('test', 'control', [], [], 'local', false, {
707+
createMutateFlag('test', 'control', [{}], [], 'local', false, {
692708
flagVersion: 4,
693709
}),
694710
];

0 commit comments

Comments
 (0)