From 9b683248a6f3aa8f5e8b8ddf4c3341128f954e74 Mon Sep 17 00:00:00 2001 From: wadii Date: Wed, 10 Jun 2026 17:17:39 +0200 Subject: [PATCH 1/3] feat: surface multivariate variant key and use it as the exposure value Co-Authored-By: Claude Opus 4.8 (1M context) --- flagsmith-core.ts | 14 +++- react.tsx | 16 ++-- test/data/identities_test_identity.json | 5 +- test/data/identities_test_identity_a.json | 5 +- test/data/identities_test_identity_b.json | 5 +- .../identities_test_identity_with_traits.json | 5 +- ...s_test_identity_with_transient_traits.json | 5 +- .../identities_test_transient_identity.json | 5 +- test/events.test.ts | 4 +- test/react-events.test.tsx | 16 ++-- test/test-constants.ts | 2 +- test/variant.test.ts | 81 +++++++++++++++++++ types.d.ts | 9 ++- 13 files changed, 136 insertions(+), 36 deletions(-) create mode 100644 test/variant.test.ts diff --git a/flagsmith-core.ts b/flagsmith-core.ts index 8ac794c3..2e1e29d5 100644 --- a/flagsmith-core.ts +++ b/flagsmith-core.ts @@ -122,7 +122,8 @@ const Flagsmith = class { flags[feature.feature.name.toLowerCase().replace(/ /g, '_')] = { id: feature.feature.id, enabled: feature.enabled, - value: feature.feature_state_value + value: feature.feature_state_value, + ...(feature.variant != null ? { variant: feature.variant } : {}), }; }); traits.forEach(trait => { @@ -994,9 +995,16 @@ const Flagsmith = class { const identifier = this.evaluationContext.identity?.identifier; if (!identifier) { this.log('Flagsmith: getExperimentFlag called without an identity; call identify() (optionally with transient: true) before using experiments to record an exposure. Returning environment flags; no exposure recorded.'); - } else if (this.loadingState.source === FlagSource.SERVER && flag) { - this.trackExposureEvent(featureName, { value: flag.value }); + return flag; } + if (!flag?.variant) { + this.log(`Flagsmith: getExperimentFlag called for "${featureName}" which has no variant; experiments require a multivariate flag. No exposure recorded.`); + return flag; + } + if (this.loadingState.source !== FlagSource.SERVER) { + return flag; + } + this.trackExposureEvent(featureName, { value: flag.variant }); return flag; }; diff --git a/react.tsx b/react.tsx index 3df8fcb0..cf841eba 100644 --- a/react.tsx +++ b/react.tsx @@ -89,7 +89,7 @@ const getExperimentRenderKey = (flagsmith: IFlagsmith | null, key: string): stri const identifier = flagsmith?.getContext().identity?.identifier ?? null // Identity is part of the key so that switching identity re-renders (and // re-fires the exposure) even when the resolved value is unchanged. - return `${identifier}|${flag?.value}|${flag?.enabled}` + return `${identifier}|${flag?.value}|${flag?.enabled}|${flag?.variant}` } export function useFlagsmithLoading() { @@ -181,9 +181,11 @@ export function useFlags, T extends strin const res: any = {} flags .map((k) => { + const variant = flagsmith!.getAllFlags()?.[k]?.variant res[k] = { enabled: flagsmith!.hasFeature(k), value: flagsmith!.getValue(k), + ...(variant != null ? { variant } : {}), } }) .concat( @@ -204,10 +206,10 @@ export function useFlags, T extends strin * not set) the flag is still returned but no exposure is recorded. * * Exposures are gated three ways: the effect only runs when the flag value, - * identity, feature or source change; a ref guard prevents duplicate fires for - * the same (feature, identifier, value); and the core EventProcessor dedupes - * within each flush window. Frequent re-renders therefore never amplify into - * extra events. + * variant, identity, feature or source change; a ref guard prevents duplicate + * fires for the same (feature, identifier, value, variant); and the core + * EventProcessor dedupes within each flush window. Frequent re-renders + * therefore never amplify into extra events. * * @experimental @internal */ @@ -236,14 +238,14 @@ export function useExperiment(featureName: string): IFlagsmithFeature | null { if (!flagsmith?.eventsEnabled || !flag) { return } - const exposureKey = `${key}:${identifier}:${flag.value}` + const exposureKey = `${key}:${identifier}:${flag.value}:${flag.variant}` if (lastExposureKey.current === exposureKey) { return } lastExposureKey.current = exposureKey flagsmith.getExperimentFlag(featureName) // eslint-disable-next-line react-hooks/exhaustive-deps - }, [flagsmith, featureName, key, identifier, flag?.value, flag?.enabled]) + }, [flagsmith, featureName, key, identifier, flag?.value, flag?.enabled, flag?.variant]) return flag } diff --git a/test/data/identities_test_identity.json b/test/data/identities_test_identity.json index 138f87c3..45dedc81 100644 --- a/test/data/identities_test_identity.json +++ b/test/data/identities_test_identity.json @@ -14,10 +14,11 @@ "feature": { "id": 6149, "name": "font_size", - "type": "STANDARD" + "type": "MULTIVARIATE" }, "enabled": true, - "feature_state_value": 16 + "feature_state_value": 16, + "variant": "control" }, { "feature": { diff --git a/test/data/identities_test_identity_a.json b/test/data/identities_test_identity_a.json index 19c16e06..3f57ddeb 100644 --- a/test/data/identities_test_identity_a.json +++ b/test/data/identities_test_identity_a.json @@ -14,10 +14,11 @@ "feature": { "id": 6149, "name": "font_size", - "type": "STANDARD" + "type": "MULTIVARIATE" }, "enabled": true, - "feature_state_value": 16 + "feature_state_value": 16, + "variant": "control" }, { "feature": { diff --git a/test/data/identities_test_identity_b.json b/test/data/identities_test_identity_b.json index 26438309..b0820373 100644 --- a/test/data/identities_test_identity_b.json +++ b/test/data/identities_test_identity_b.json @@ -14,10 +14,11 @@ "feature": { "id": 6149, "name": "font_size", - "type": "STANDARD" + "type": "MULTIVARIATE" }, "enabled": true, - "feature_state_value": 16 + "feature_state_value": 16, + "variant": "control" }, { "feature": { diff --git a/test/data/identities_test_identity_with_traits.json b/test/data/identities_test_identity_with_traits.json index c2c668ea..baacf74e 100644 --- a/test/data/identities_test_identity_with_traits.json +++ b/test/data/identities_test_identity_with_traits.json @@ -14,10 +14,11 @@ "feature": { "id": 6149, "name": "font_size", - "type": "STANDARD" + "type": "MULTIVARIATE" }, "enabled": true, - "feature_state_value": 16 + "feature_state_value": 16, + "variant": "control" }, { "feature": { diff --git a/test/data/identities_test_identity_with_transient_traits.json b/test/data/identities_test_identity_with_transient_traits.json index 0a934602..4b85aedc 100644 --- a/test/data/identities_test_identity_with_transient_traits.json +++ b/test/data/identities_test_identity_with_transient_traits.json @@ -14,10 +14,11 @@ "feature": { "id": 6149, "name": "font_size", - "type": "STANDARD" + "type": "MULTIVARIATE" }, "enabled": true, - "feature_state_value": 16 + "feature_state_value": 16, + "variant": "control" }, { "feature": { diff --git a/test/data/identities_test_transient_identity.json b/test/data/identities_test_transient_identity.json index 74b363ab..d95721f2 100644 --- a/test/data/identities_test_transient_identity.json +++ b/test/data/identities_test_transient_identity.json @@ -14,10 +14,11 @@ "feature": { "id": 6149, "name": "font_size", - "type": "STANDARD" + "type": "MULTIVARIATE" }, "enabled": true, - "feature_state_value": 16 + "feature_state_value": 16, + "variant": "control" }, { "feature": { diff --git a/test/events.test.ts b/test/events.test.ts index 2060da11..c541e066 100644 --- a/test/events.test.ts +++ b/test/events.test.ts @@ -105,7 +105,7 @@ describe('getExperimentFlag', () => { await flagsmith.init(initConfig); // fetches identity flags -> source SERVER const flag = flagsmith.getExperimentFlag('font_size'); - expect(flag).toEqual(expect.objectContaining({ enabled: true, value: 16 })); + expect(flag).toEqual(expect.objectContaining({ enabled: true, value: 16, variant: 'control' })); await flagsmith.flushEvents(); const events = JSON.parse(eventCalls(mockFetch)[0][1].body).events; @@ -114,7 +114,7 @@ describe('getExperimentFlag', () => { expect(exposures[0]).toEqual(expect.objectContaining({ feature_name: 'font_size', identifier: testIdentity, - value: '16', + value: 'control', })); }); diff --git a/test/react-events.test.tsx b/test/react-events.test.tsx index fd52845f..d58d2a2c 100644 --- a/test/react-events.test.tsx +++ b/test/react-events.test.tsx @@ -49,7 +49,7 @@ describe('useExperiment', () => { expect(fired[0]).toEqual(expect.objectContaining({ feature_name: 'font_size', identifier: testIdentity, - value: '16', + value: 'control', })) }) @@ -86,7 +86,7 @@ describe('useExperiment', () => { expect(exposures(mockFetch)).toHaveLength(1) }) - test('a variant value change fires a second exposure', async () => { + test('a variant change fires a second exposure even when the value is unchanged', async () => { const { flagsmith, initConfig, mockFetch } = getFlagsmith(eventsConfig({ identity: testIdentity })) render( @@ -98,22 +98,22 @@ describe('useExperiment', () => { expect(JSON.parse(screen.getByTestId('exp').innerHTML)?.value).toBe(16) }) - // Next fetch returns font_size = 20 for the identified user. + // Next fetch buckets the user into a different variant with the same value. getMockFetchWithValue(mockFetch, { flags: [ - { enabled: true, feature_state_value: 20, feature: { id: 6149, name: 'font_size' } }, + { enabled: true, feature_state_value: 16, variant: 'large', feature: { id: 6149, name: 'font_size' } }, ], traits: [], }) await flagsmith.getFlags() await waitFor(() => { - expect(JSON.parse(screen.getByTestId('exp').innerHTML)?.value).toBe(20) + expect(JSON.parse(screen.getByTestId('exp').innerHTML)?.variant).toBe('large') }) await flagsmith.flushEvents() const values = exposures(mockFetch).map((e: any) => e.value) - expect(values).toEqual(['16', '20']) + expect(values).toEqual(['control', 'large']) }) test('fires a fresh exposure when identity changes even if the value is unchanged', async () => { @@ -128,10 +128,10 @@ describe('useExperiment', () => { expect(JSON.parse(screen.getByTestId('exp').innerHTML)?.value).toBe(16) }) - // A different identity that resolves the SAME font_size value (16). + // A different identity that resolves the SAME font_size variant and value. getMockFetchWithValue(mockFetch, { flags: [ - { enabled: true, feature_state_value: 16, feature: { id: 6149, name: 'font_size' } }, + { enabled: true, feature_state_value: 16, variant: 'control', feature: { id: 6149, name: 'font_size' } }, ], traits: [], }) diff --git a/test/test-constants.ts b/test/test-constants.ts index b5cec7e2..15761db5 100644 --- a/test/test-constants.ts +++ b/test/test-constants.ts @@ -44,7 +44,7 @@ export const identityState = { enabled: true, value: 'https://s3-us-west-2.amazonaws.com/com.uppercut.hero-images/assets/0466/comps/466_03314.jpg' }, - font_size: { id: 6149, enabled: true, value: 16 }, + font_size: { id: 6149, enabled: true, value: 16, variant: 'control' }, json_value: { id: 80317, enabled: true, value: '{"title":"Hello World"}' }, number_value: { id: 80318, enabled: true, value: 1 }, off_value: { id: 80319, enabled: false, value: null }, diff --git a/test/variant.test.ts b/test/variant.test.ts new file mode 100644 index 00000000..3bcc6ce1 --- /dev/null +++ b/test/variant.test.ts @@ -0,0 +1,81 @@ +import { getFlagsmith, testIdentity } from './test-constants'; +import { FLAG_EXPOSURE_EVENT } from '../event-processor'; + +const eventsUrl = 'https://events.test/'; + +// Identity payload where one flag carries a multivariate `variant` key, one is +// in the control bucket, and one is a plain (non-variant) flag. +const identityWithVariant = { + identifier: testIdentity, + traits: [], + flags: [ + { + feature: { id: 1, name: 'experiment', type: 'MULTIVARIATE' }, + enabled: true, + feature_state_value: 'variant_value', + variant: 'variant_a', + }, + { + feature: { id: 2, name: 'control_experiment', type: 'MULTIVARIATE' }, + enabled: true, + feature_state_value: 'control_value', + variant: 'control', + }, + { + feature: { id: 3, name: 'font_size', type: 'STANDARD' }, + enabled: true, + feature_state_value: 16, + }, + ], +}; + +async function initWithVariants() { + const { flagsmith, initConfig, mockFetch } = getFlagsmith({ + enableEvents: true, + eventProcessorConfig: { eventsApiUrl: eventsUrl, flushInterval: 60000 }, + identity: testIdentity, + }); + // Replace the identity endpoint response with one that carries variant keys. + mockFetch.mockImplementation(async (url: string) => { + if (url.includes('/v1/events')) { + return { status: 202, text: () => Promise.resolve('') }; + } + if (url.includes('analytics/flags')) { + return { status: 200, text: () => Promise.resolve('{}') }; + } + if (url.includes('/identities/')) { + return { status: 200, text: () => Promise.resolve(JSON.stringify(identityWithVariant)) }; + } + throw new Error('Please mock the call to ' + url); + }); + await flagsmith.init(initConfig); + return { flagsmith, mockFetch }; +} + +describe('variant key', () => { + test('surfaces the variant key, "control" bucket, and omits it for plain flags', async () => { + const { flagsmith } = await initWithVariants(); + const flags = flagsmith.getAllFlags(); + expect(flags.experiment).toEqual( + expect.objectContaining({ enabled: true, value: 'variant_value', variant: 'variant_a' }) + ); + expect(flags.control_experiment.variant).toBe('control'); + expect(flags.font_size.variant).toBeUndefined(); + }); + + test('getExperimentFlag uses the variant as the exposure value and skips flags without one', async () => { + const { flagsmith, mockFetch } = await initWithVariants(); + + flagsmith.getExperimentFlag('experiment'); + flagsmith.getExperimentFlag('control_experiment'); + flagsmith.getExperimentFlag('font_size'); + await flagsmith.flushEvents(); + + const [, opts] = mockFetch.mock.calls.find(([url]) => url.includes('/v1/events'))!; + const exposures = JSON.parse(opts.body).events.filter((e: any) => e.event === FLAG_EXPOSURE_EVENT); + expect(exposures).toEqual([ + expect.objectContaining({ feature_name: 'experiment', value: 'variant_a' }), + expect.objectContaining({ feature_name: 'control_experiment', value: 'control' }), + ]); + }); +}); diff --git a/types.d.ts b/types.d.ts index 1cbcc869..c3799670 100644 --- a/types.d.ts +++ b/types.d.ts @@ -14,6 +14,7 @@ export interface IFlagsmithFeature { id?: number; enabled: boolean; value: Value; + variant?: string; } export declare type IFlagsmithTrait = IFlagsmithValue | TraitEvaluationContext; @@ -162,6 +163,7 @@ export interface IFlagsmithResponse { flags?: { enabled: boolean; feature_state_value: IFlagsmithValue; + variant?: string; feature: { id: number; name: string; @@ -316,9 +318,10 @@ T extends string = string }) => void; /** * Resolve a flag for the currently identified user and fire one - * "$flag_exposure" event (skipped unless flags were loaded from the server - * and the feature exists). When events are disabled (enableEvents is not - * set) this degrades to a plain flag read. + * "$flag_exposure" event with the selected variant as its value (skipped + * unless flags were loaded from the server and the flag has a variant). + * When events are disabled (enableEvents is not set) this degrades to a + * plain flag read. * @experimental @internal */ getExperimentFlag: (featureName: string) => IFlagsmithFeature | null; From b5965f0e9cf6a5cdfc3fcfd01efdee8c9c5594df Mon Sep 17 00:00:00 2001 From: wadii Date: Thu, 11 Jun 2026 11:23:15 +0200 Subject: [PATCH 2/3] feat: re-render useFlags on variant-only changes and treat empty variant keys as absent --- flagsmith-core.ts | 2 +- react.tsx | 8 +-- .../identities_test_experiment_identity.json | 25 +++++++++ test/data/identities_test_identity.json | 5 +- test/data/identities_test_identity_a.json | 5 +- test/data/identities_test_identity_b.json | 5 +- .../identities_test_identity_with_traits.json | 5 +- ...s_test_identity_with_transient_traits.json | 5 +- .../identities_test_transient_identity.json | 5 +- test/events.test.ts | 8 +-- test/react-events.test.tsx | 14 ++--- test/react-variant.test.tsx | 51 +++++++++++++++++++ test/test-constants.ts | 5 +- test/variant.test.ts | 39 ++++++++++++++ 14 files changed, 148 insertions(+), 34 deletions(-) create mode 100644 test/data/identities_test_experiment_identity.json create mode 100644 test/react-variant.test.tsx diff --git a/flagsmith-core.ts b/flagsmith-core.ts index 2e1e29d5..51a7b022 100644 --- a/flagsmith-core.ts +++ b/flagsmith-core.ts @@ -123,7 +123,7 @@ const Flagsmith = class { id: feature.feature.id, enabled: feature.enabled, value: feature.feature_state_value, - ...(feature.variant != null ? { variant: feature.variant } : {}), + ...(feature.variant ? { variant: feature.variant } : {}), }; }); traits.forEach(trait => { diff --git a/react.tsx b/react.tsx index cf841eba..c19bf055 100644 --- a/react.tsx +++ b/react.tsx @@ -75,10 +75,12 @@ const flagsAsArray = (_flags: any): string[] => { throw new Error('Flagsmith: please supply an array of strings or a single string of flag keys to useFlags') } +const normalizeFlagKey = (key: string) => key.toLowerCase().replace(/ /g, '_') + const getRenderKey = (flagsmith: IFlagsmith, flags: string[], traits: string[] = []) => { return flags .map((k) => { - return `${flagsmith.getValue(k)}${flagsmith.hasFeature(k)}` + return `${flagsmith.getValue(k)}${flagsmith.hasFeature(k)}${flagsmith.getAllFlags()?.[normalizeFlagKey(k)]?.variant}` }) .concat(traits.map((t) => `${flagsmith.getTrait(t)}`)) .join(',') @@ -181,7 +183,7 @@ export function useFlags, T extends strin const res: any = {} flags .map((k) => { - const variant = flagsmith!.getAllFlags()?.[k]?.variant + const variant = flagsmith!.getAllFlags()?.[normalizeFlagKey(k)]?.variant res[k] = { enabled: flagsmith!.hasFeature(k), value: flagsmith!.getValue(k), @@ -215,7 +217,7 @@ export function useFlags, T extends strin */ export function useExperiment(featureName: string): IFlagsmithFeature | null { const flagsmith = useContext(FlagsmithContext) - const key = featureName.toLowerCase().replace(/ /g, '_') + const key = normalizeFlagKey(featureName) const lastExposureKey = useRef(null) const [, setRenderKey] = useState(() => getExperimentRenderKey(flagsmith, key)) diff --git a/test/data/identities_test_experiment_identity.json b/test/data/identities_test_experiment_identity.json new file mode 100644 index 00000000..eae5c4b3 --- /dev/null +++ b/test/data/identities_test_experiment_identity.json @@ -0,0 +1,25 @@ +{ + "identifier": "test_experiment_identity", + "flags": [ + { + "feature": { + "id": 1804, + "name": "hero", + "type": "STANDARD" + }, + "enabled": true, + "feature_state_value": "https://s3-us-west-2.amazonaws.com/com.uppercut.hero-images/assets/0466/comps/466_03314.jpg" + }, + { + "feature": { + "id": 6149, + "name": "font_size", + "type": "MULTIVARIATE" + }, + "enabled": true, + "feature_state_value": 16, + "variant": "control" + } + ], + "traits": [] +} diff --git a/test/data/identities_test_identity.json b/test/data/identities_test_identity.json index 45dedc81..138f87c3 100644 --- a/test/data/identities_test_identity.json +++ b/test/data/identities_test_identity.json @@ -14,11 +14,10 @@ "feature": { "id": 6149, "name": "font_size", - "type": "MULTIVARIATE" + "type": "STANDARD" }, "enabled": true, - "feature_state_value": 16, - "variant": "control" + "feature_state_value": 16 }, { "feature": { diff --git a/test/data/identities_test_identity_a.json b/test/data/identities_test_identity_a.json index 3f57ddeb..19c16e06 100644 --- a/test/data/identities_test_identity_a.json +++ b/test/data/identities_test_identity_a.json @@ -14,11 +14,10 @@ "feature": { "id": 6149, "name": "font_size", - "type": "MULTIVARIATE" + "type": "STANDARD" }, "enabled": true, - "feature_state_value": 16, - "variant": "control" + "feature_state_value": 16 }, { "feature": { diff --git a/test/data/identities_test_identity_b.json b/test/data/identities_test_identity_b.json index b0820373..26438309 100644 --- a/test/data/identities_test_identity_b.json +++ b/test/data/identities_test_identity_b.json @@ -14,11 +14,10 @@ "feature": { "id": 6149, "name": "font_size", - "type": "MULTIVARIATE" + "type": "STANDARD" }, "enabled": true, - "feature_state_value": 16, - "variant": "control" + "feature_state_value": 16 }, { "feature": { diff --git a/test/data/identities_test_identity_with_traits.json b/test/data/identities_test_identity_with_traits.json index baacf74e..c2c668ea 100644 --- a/test/data/identities_test_identity_with_traits.json +++ b/test/data/identities_test_identity_with_traits.json @@ -14,11 +14,10 @@ "feature": { "id": 6149, "name": "font_size", - "type": "MULTIVARIATE" + "type": "STANDARD" }, "enabled": true, - "feature_state_value": 16, - "variant": "control" + "feature_state_value": 16 }, { "feature": { diff --git a/test/data/identities_test_identity_with_transient_traits.json b/test/data/identities_test_identity_with_transient_traits.json index 4b85aedc..0a934602 100644 --- a/test/data/identities_test_identity_with_transient_traits.json +++ b/test/data/identities_test_identity_with_transient_traits.json @@ -14,11 +14,10 @@ "feature": { "id": 6149, "name": "font_size", - "type": "MULTIVARIATE" + "type": "STANDARD" }, "enabled": true, - "feature_state_value": 16, - "variant": "control" + "feature_state_value": 16 }, { "feature": { diff --git a/test/data/identities_test_transient_identity.json b/test/data/identities_test_transient_identity.json index d95721f2..74b363ab 100644 --- a/test/data/identities_test_transient_identity.json +++ b/test/data/identities_test_transient_identity.json @@ -14,11 +14,10 @@ "feature": { "id": 6149, "name": "font_size", - "type": "MULTIVARIATE" + "type": "STANDARD" }, "enabled": true, - "feature_state_value": 16, - "variant": "control" + "feature_state_value": 16 }, { "feature": { diff --git a/test/events.test.ts b/test/events.test.ts index c541e066..310954ad 100644 --- a/test/events.test.ts +++ b/test/events.test.ts @@ -1,4 +1,4 @@ -import { getFlagsmith, environmentID, testIdentity } from './test-constants'; +import { getFlagsmith, environmentID, testIdentity, experimentIdentity } from './test-constants'; import { FLAG_EXPOSURE_EVENT } from '../event-processor'; const eventsUrl = 'https://events.test/'; @@ -101,7 +101,7 @@ describe('trackEvent', () => { describe('getExperimentFlag', () => { test('returns the flag and fires one $flag_exposure when identified and source is SERVER', async () => { - const { flagsmith, initConfig, mockFetch } = getFlagsmith(eventsConfig({ identity: testIdentity })); + const { flagsmith, initConfig, mockFetch } = getFlagsmith(eventsConfig({ identity: experimentIdentity })); await flagsmith.init(initConfig); // fetches identity flags -> source SERVER const flag = flagsmith.getExperimentFlag('font_size'); @@ -113,7 +113,7 @@ describe('getExperimentFlag', () => { expect(exposures).toHaveLength(1); expect(exposures[0]).toEqual(expect.objectContaining({ feature_name: 'font_size', - identifier: testIdentity, + identifier: experimentIdentity, value: 'control', })); }); @@ -154,7 +154,7 @@ describe('getExperimentFlag', () => { }); test('repeated calls collapse to a single exposure within the window', async () => { - const { flagsmith, initConfig, mockFetch } = getFlagsmith(eventsConfig({ identity: testIdentity })); + const { flagsmith, initConfig, mockFetch } = getFlagsmith(eventsConfig({ identity: experimentIdentity })); await flagsmith.init(initConfig); flagsmith.getExperimentFlag('font_size'); diff --git a/test/react-events.test.tsx b/test/react-events.test.tsx index d58d2a2c..43fc9e4a 100644 --- a/test/react-events.test.tsx +++ b/test/react-events.test.tsx @@ -1,7 +1,7 @@ import React, { FC, useState } from 'react' import { render, screen, waitFor, fireEvent } from '@testing-library/react' import { FlagsmithProvider, useExperiment } from '../react' -import { getFlagsmith, testIdentity, getMockFetchWithValue } from './test-constants' +import { getFlagsmith, testIdentity, experimentIdentity, getMockFetchWithValue } from './test-constants' const eventsUrl = 'https://events.test/' @@ -30,7 +30,7 @@ const Probe: FC<{ feature: string }> = ({ feature }) => { describe('useExperiment', () => { test('fires one $flag_exposure when identified and source is SERVER', async () => { - const { flagsmith, initConfig, mockFetch } = getFlagsmith(eventsConfig({ identity: testIdentity })) + const { flagsmith, initConfig, mockFetch } = getFlagsmith(eventsConfig({ identity: experimentIdentity })) render( @@ -48,13 +48,13 @@ describe('useExperiment', () => { expect(fired).toHaveLength(1) expect(fired[0]).toEqual(expect.objectContaining({ feature_name: 'font_size', - identifier: testIdentity, + identifier: experimentIdentity, value: 'control', })) }) test('repeated parent re-renders produce only one exposure', async () => { - const { flagsmith, initConfig, mockFetch } = getFlagsmith(eventsConfig({ identity: testIdentity })) + const { flagsmith, initConfig, mockFetch } = getFlagsmith(eventsConfig({ identity: experimentIdentity })) const Storm: FC = () => { const [n, setN] = useState(0) @@ -87,7 +87,7 @@ describe('useExperiment', () => { }) test('a variant change fires a second exposure even when the value is unchanged', async () => { - const { flagsmith, initConfig, mockFetch } = getFlagsmith(eventsConfig({ identity: testIdentity })) + const { flagsmith, initConfig, mockFetch } = getFlagsmith(eventsConfig({ identity: experimentIdentity })) render( @@ -117,7 +117,7 @@ describe('useExperiment', () => { }) test('fires a fresh exposure when identity changes even if the value is unchanged', async () => { - const { flagsmith, initConfig, mockFetch } = getFlagsmith(eventsConfig({ identity: testIdentity })) + const { flagsmith, initConfig, mockFetch } = getFlagsmith(eventsConfig({ identity: experimentIdentity })) render( @@ -141,7 +141,7 @@ describe('useExperiment', () => { await flagsmith.flushEvents() expect(exposures(mockFetch)).toHaveLength(2) }) - expect(exposures(mockFetch).map((e: any) => e.identifier)).toEqual([testIdentity, 'other_identity']) + expect(exposures(mockFetch).map((e: any) => e.identifier)).toEqual([experimentIdentity, 'other_identity']) }) test('renders the flag but fires no exposure when events are disabled', async () => { diff --git a/test/react-variant.test.tsx b/test/react-variant.test.tsx new file mode 100644 index 00000000..a68d6b5d --- /dev/null +++ b/test/react-variant.test.tsx @@ -0,0 +1,51 @@ +import React, { FC } from 'react' +import { render, screen, waitFor } from '@testing-library/react' +import { FlagsmithProvider, useFlags } from '../react' +import { getFlagsmith, experimentIdentity, getMockFetchWithValue } from './test-constants' + +const FlagsPage: FC<{ flags: string[] }> = ({ flags: names }) => { + const flags = useFlags(names) + return
{JSON.stringify(flags)}
+} + +const renderedFlags = () => JSON.parse(screen.getByTestId('flags').innerHTML) + +describe('useFlags variant', () => { + test('re-renders when only the variant changes', async () => { + const { flagsmith, initConfig, mockFetch } = getFlagsmith({ identity: experimentIdentity }) + render( + + + + ) + + await waitFor(() => { + expect(renderedFlags().font_size).toEqual({ enabled: true, value: 16, variant: 'control' }) + }) + + getMockFetchWithValue(mockFetch, { + flags: [ + { enabled: true, feature_state_value: 16, variant: 'large', feature: { id: 6149, name: 'font_size' } }, + ], + traits: [], + }) + await flagsmith.getFlags() + + await waitFor(() => { + expect(renderedFlags().font_size.variant).toBe('large') + }) + }) + + test('surfaces the variant when the flag name needs normalising', async () => { + const { flagsmith, initConfig } = getFlagsmith({ identity: experimentIdentity }) + render( + + + + ) + + await waitFor(() => { + expect(renderedFlags()['Font Size']).toEqual({ enabled: true, value: 16, variant: 'control' }) + }) + }) +}) diff --git a/test/test-constants.ts b/test/test-constants.ts index 15761db5..7c91dfcf 100644 --- a/test/test-constants.ts +++ b/test/test-constants.ts @@ -25,6 +25,7 @@ export const defaultState = { }; export const testIdentity = 'test_identity' +export const experimentIdentity = 'test_experiment_identity' export const identityState = { api: 'https://edge.api.flagsmith.com/api/v1/', identity: testIdentity, @@ -44,7 +45,7 @@ export const identityState = { enabled: true, value: 'https://s3-us-west-2.amazonaws.com/com.uppercut.hero-images/assets/0466/comps/466_03314.jpg' }, - font_size: { id: 6149, enabled: true, value: 16, variant: 'control' }, + font_size: { id: 6149, enabled: true, value: 16 }, json_value: { id: 80317, enabled: true, value: '{"title":"Hello World"}' }, number_value: { id: 80318, enabled: true, value: 1 }, off_value: { id: 80319, enabled: false, value: null }, @@ -87,6 +88,8 @@ export function getFlagsmith(config: Partial = {}) { return {status: 200, text: () => fs.readFile('./test/data/flags.json', 'utf8')} case 'https://edge.api.flagsmith.com/api/v1/identities/?identifier=' + testIdentity: return {status: 200, text: () => fs.readFile(`./test/data/identities_${testIdentity}.json`, 'utf8')} + case 'https://edge.api.flagsmith.com/api/v1/identities/?identifier=' + experimentIdentity: + return {status: 200, text: () => fs.readFile(`./test/data/identities_${experimentIdentity}.json`, 'utf8')} } throw new Error('Please mock the call to ' + url) diff --git a/test/variant.test.ts b/test/variant.test.ts index 3bcc6ce1..941c578a 100644 --- a/test/variant.test.ts +++ b/test/variant.test.ts @@ -26,6 +26,18 @@ const identityWithVariant = { enabled: true, feature_state_value: 16, }, + { + feature: { id: 4, name: 'empty_variant', type: 'MULTIVARIATE' }, + enabled: true, + feature_state_value: 'value', + variant: '', + }, + { + feature: { id: 5, name: 'empty_value_experiment', type: 'MULTIVARIATE' }, + enabled: true, + feature_state_value: '', + variant: 'variant_b', + }, ], }; @@ -78,4 +90,31 @@ describe('variant key', () => { expect.objectContaining({ feature_name: 'control_experiment', value: 'control' }), ]); }); + + test('treats an empty-string variant as absent and records no exposure for it', async () => { + const { flagsmith, mockFetch } = await initWithVariants(); + + expect(flagsmith.getAllFlags().empty_variant.variant).toBeUndefined(); + + flagsmith.getExperimentFlag('empty_variant'); + await flagsmith.flushEvents(); + expect(mockFetch.mock.calls.find(([url]) => url.includes('/v1/events'))).toBeUndefined(); + }); + + test('surfaces the variant and records an exposure when the flag value is an empty string', async () => { + const { flagsmith, mockFetch } = await initWithVariants(); + + expect(flagsmith.getAllFlags().empty_value_experiment).toEqual( + expect.objectContaining({ enabled: true, value: '', variant: 'variant_b' }) + ); + + flagsmith.getExperimentFlag('empty_value_experiment'); + await flagsmith.flushEvents(); + + const [, opts] = mockFetch.mock.calls.find(([url]) => url.includes('/v1/events'))!; + const exposures = JSON.parse(opts.body).events.filter((e: any) => e.event === FLAG_EXPOSURE_EVENT); + expect(exposures).toEqual([ + expect.objectContaining({ feature_name: 'empty_value_experiment', value: 'variant_b' }), + ]); + }); }); From b7847ac2a1d6548cc022ba29fa1f4653159bf776 Mon Sep 17 00:00:00 2001 From: wadii Date: Thu, 11 Jun 2026 13:55:32 +0200 Subject: [PATCH 3/3] feat: log flag is null in experiment --- flagsmith-core.ts | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/flagsmith-core.ts b/flagsmith-core.ts index 51a7b022..6efcab3d 100644 --- a/flagsmith-core.ts +++ b/flagsmith-core.ts @@ -997,7 +997,11 @@ const Flagsmith = class { this.log('Flagsmith: getExperimentFlag called without an identity; call identify() (optionally with transient: true) before using experiments to record an exposure. Returning environment flags; no exposure recorded.'); return flag; } - if (!flag?.variant) { + if (!flag) { + this.log(`Flagsmith: getExperimentFlag called for "${featureName}" which does not exist. No exposure recorded.`); + return null; + } + if (!flag.variant) { this.log(`Flagsmith: getExperimentFlag called for "${featureName}" which has no variant; experiments require a multivariate flag. No exposure recorded.`); return flag; }