Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
40 changes: 39 additions & 1 deletion packages/experiment-tag/src/experiment.ts
Original file line number Diff line number Diff line change
Expand Up @@ -186,6 +186,27 @@ export class DefaultWebExperimentClient implements WebExperimentClient {
}
});

// Flags that depend on at least one remote flag are also considered remote.
// Iterate to fixed point to handle transitive dependencies.
// Track which keys were promoted so we can exclude only those from
// localFlagKeys — flags that are directly 'remote' but cached in session
// storage as 'local' should still be applied locally (fast-path rendering).
const transitivelyPromotedKeys = new Set<string>();
let changed = true;
while (changed) {
Comment thread
tyiuhc marked this conversation as resolved.
changed = false;
this.initialFlags.forEach((flag: EvaluationFlag) => {
if (
!this.remoteFlagKeys.includes(flag.key) &&
flag.dependencies?.some((dep) => this.remoteFlagKeys.includes(dep))
) {
this.remoteFlagKeys.push(flag.key);
transitivelyPromotedKeys.add(flag.key);
changed = true;
}
});
}
Comment thread
cursor[bot] marked this conversation as resolved.

const initialFlagsString = JSON.stringify(this.initialFlags);

// initialize the experiment
Expand All @@ -202,9 +223,14 @@ export class DefaultWebExperimentClient implements WebExperimentClient {
...this.config,
});
// Get all the locally available flag keys from the SDK.
// Exclude flags promoted to remoteFlagKeys via the dependency loop above —
// their remote dependencies must be fetched first or mutex/holdout bucketing
// will run against stale parent-flag state and assign the wrong variant.
const variants = this.experimentClient.all();
this.localFlagKeys = Object.keys(variants).filter(
(key) => variants[key]?.metadata?.evaluationMode === 'local',
(key) =>
variants[key]?.metadata?.evaluationMode === 'local' &&
!transitivelyPromotedKeys.has(key),
);
this.messageBus = new MessageBus();
}
Expand Down Expand Up @@ -391,6 +417,18 @@ export class DefaultWebExperimentClient implements WebExperimentClient {
this.experimentClient.setUser(enrichedUser);
this.updateUserWithBehaviors();

// Holdout/mutex bucketing requires user identity (user_id,
// device_id) — wait for the integration's setup() only when present.
const hasHoldoutOrMutex = this.initialFlags.some(
(flag: EvaluationFlag) =>
flag.key.startsWith('holdout-') || flag.key.startsWith('mutex-'),
);
if (hasHoldoutOrMutex) {
// eslint-disable-next-line @typescript-eslint/ban-ts-comment
// @ts-ignore
await this.experimentClient.integrationManager.ready();
}

if (!this.isRemoteBlocking) {
removeAntiFlickerCss();
}
Expand Down
73 changes: 73 additions & 0 deletions packages/experiment-tag/test/experiment.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -609,6 +609,79 @@ describe('initializeExperiment', () => {
expect(mockExposure).toHaveBeenCalledWith('test-2');
});

test('remote evaluation - local flag with remote dependency not applied before remote fetch', async () => {
// Depth-2 chain: local-grandchild → local-dep → remote-parent.
// initialFlags ordered [grandchild, dep, parent] — the adversarial ordering
// that a single forEach pass would fail on, proving the fixed-point loop is needed.
const remoteParentFlag = {
...createMutateFlag('remote-parent', 'treatment', [], [], 'remote'),
};
const localDepFlag = {
...createMutateFlag('local-dep', 'treatment', []),
dependencies: ['remote-parent'],
};
const localGrandchildFlag = {
...createMutateFlag('local-grandchild', 'treatment', [
DEFAULT_MUTATE_SCOPE,
]),
dependencies: ['local-dep'],
};
const initialFlags = [localGrandchildFlag, localDepFlag, remoteParentFlag];
const remoteFlags = [
createMutateFlag('remote-parent', 'treatment', [], [], 'remote'),
];
const mockHttpClient = new MockHttpClient(JSON.stringify(remoteFlags));

const applyVariantsSpy = jest.spyOn(
DefaultWebExperimentClient.prototype as any,
'applyVariants',
);

await DefaultWebExperimentClient.getInstance(
stringify(apiKey),
{
initialFlags: JSON.stringify(initialFlags),
pageObjects: JSON.stringify({
'remote-parent': createPageObject(
'A',
'url_change',
undefined,
'http://test.com',
),
'local-dep': createPageObject(
'A',
'url_change',
undefined,
'http://test.com',
),
'local-grandchild': createPageObject(
'A',
'url_change',
undefined,
'http://test.com',
),
}),
},
{
httpClient: mockHttpClient,
},
).start();

// The first applyVariants call (for local flags) must not include either transitive dep
const firstCallFlagKeys: string[] =
(applyVariantsSpy.mock.calls[0]?.[0] as { flagKeys?: string[] })
?.flagKeys ?? [];
expect(firstCallFlagKeys).not.toContain('local-dep');
expect(firstCallFlagKeys).not.toContain('local-grandchild');

// Both should be applied in the remote pass (after fetch)
const allCalledKeys: string[] = applyVariantsSpy.mock.calls.flatMap(
(call) => (call[0] as { flagKeys?: string[] })?.flagKeys ?? [],
);
expect(allCalledKeys).toContain('local-dep');
expect(allCalledKeys).toContain('local-grandchild');
});

test('remote evaluation - fetch fail, locally evaluate remote and local flags success', async () => {
const initialFlags = [
// remote flag
Expand Down
Loading