Skip to content

Commit 5b9e2d8

Browse files
Zaimwa9claude
andauthored
feat: surface multivariate variant key and use it as the exposure value (#401)
Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
1 parent 621ffd6 commit 5b9e2d8

9 files changed

Lines changed: 254 additions & 36 deletions

flagsmith-core.ts

Lines changed: 15 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -122,7 +122,8 @@ const Flagsmith = class {
122122
flags[feature.feature.name.toLowerCase().replace(/ /g, '_')] = {
123123
id: feature.feature.id,
124124
enabled: feature.enabled,
125-
value: feature.feature_state_value
125+
value: feature.feature_state_value,
126+
...(feature.variant ? { variant: feature.variant } : {}),
126127
};
127128
});
128129
traits.forEach(trait => {
@@ -994,9 +995,20 @@ const Flagsmith = class {
994995
const identifier = this.evaluationContext.identity?.identifier;
995996
if (!identifier) {
996997
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.');
997-
} else if (this.loadingState.source === FlagSource.SERVER && flag) {
998-
this.trackExposureEvent(featureName, { value: flag.value });
998+
return flag;
999999
}
1000+
if (!flag) {
1001+
this.log(`Flagsmith: getExperimentFlag called for "${featureName}" which does not exist. No exposure recorded.`);
1002+
return null;
1003+
}
1004+
if (!flag.variant) {
1005+
this.log(`Flagsmith: getExperimentFlag called for "${featureName}" which has no variant; experiments require a multivariate flag. No exposure recorded.`);
1006+
return flag;
1007+
}
1008+
if (this.loadingState.source !== FlagSource.SERVER) {
1009+
return flag;
1010+
}
1011+
this.trackExposureEvent(featureName, { value: flag.variant });
10001012
return flag;
10011013
};
10021014

react.tsx

Lines changed: 13 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -75,10 +75,12 @@ const flagsAsArray = (_flags: any): string[] => {
7575
throw new Error('Flagsmith: please supply an array of strings or a single string of flag keys to useFlags')
7676
}
7777

78+
const normalizeFlagKey = (key: string) => key.toLowerCase().replace(/ /g, '_')
79+
7880
const getRenderKey = (flagsmith: IFlagsmith, flags: string[], traits: string[] = []) => {
7981
return flags
8082
.map((k) => {
81-
return `${flagsmith.getValue(k)}${flagsmith.hasFeature(k)}`
83+
return `${flagsmith.getValue(k)}${flagsmith.hasFeature(k)}${flagsmith.getAllFlags()?.[normalizeFlagKey(k)]?.variant}`
8284
})
8385
.concat(traits.map((t) => `${flagsmith.getTrait(t)}`))
8486
.join(',')
@@ -89,7 +91,7 @@ const getExperimentRenderKey = (flagsmith: IFlagsmith | null, key: string): stri
8991
const identifier = flagsmith?.getContext().identity?.identifier ?? null
9092
// Identity is part of the key so that switching identity re-renders (and
9193
// re-fires the exposure) even when the resolved value is unchanged.
92-
return `${identifier}|${flag?.value}|${flag?.enabled}`
94+
return `${identifier}|${flag?.value}|${flag?.enabled}|${flag?.variant}`
9395
}
9496

9597
export function useFlagsmithLoading() {
@@ -181,9 +183,11 @@ export function useFlags<F extends string | Record<string, any>, T extends strin
181183
const res: any = {}
182184
flags
183185
.map((k) => {
186+
const variant = flagsmith!.getAllFlags()?.[normalizeFlagKey(k)]?.variant
184187
res[k] = {
185188
enabled: flagsmith!.hasFeature(k),
186189
value: flagsmith!.getValue(k),
190+
...(variant != null ? { variant } : {}),
187191
}
188192
})
189193
.concat(
@@ -204,16 +208,16 @@ export function useFlags<F extends string | Record<string, any>, T extends strin
204208
* not set) the flag is still returned but no exposure is recorded.
205209
*
206210
* Exposures are gated three ways: the effect only runs when the flag value,
207-
* identity, feature or source change; a ref guard prevents duplicate fires for
208-
* the same (feature, identifier, value); and the core EventProcessor dedupes
209-
* within each flush window. Frequent re-renders therefore never amplify into
210-
* extra events.
211+
* variant, identity, feature or source change; a ref guard prevents duplicate
212+
* fires for the same (feature, identifier, value, variant); and the core
213+
* EventProcessor dedupes within each flush window. Frequent re-renders
214+
* therefore never amplify into extra events.
211215
*
212216
* @experimental @internal
213217
*/
214218
export function useExperiment(featureName: string): IFlagsmithFeature | null {
215219
const flagsmith = useContext(FlagsmithContext)
216-
const key = featureName.toLowerCase().replace(/ /g, '_')
220+
const key = normalizeFlagKey(featureName)
217221
const lastExposureKey = useRef<string | null>(null)
218222
const [, setRenderKey] = useState<string>(() => getExperimentRenderKey(flagsmith, key))
219223

@@ -236,14 +240,14 @@ export function useExperiment(featureName: string): IFlagsmithFeature | null {
236240
if (!flagsmith?.eventsEnabled || !flag) {
237241
return
238242
}
239-
const exposureKey = `${key}:${identifier}:${flag.value}`
243+
const exposureKey = `${key}:${identifier}:${flag.value}:${flag.variant}`
240244
if (lastExposureKey.current === exposureKey) {
241245
return
242246
}
243247
lastExposureKey.current = exposureKey
244248
flagsmith.getExperimentFlag(featureName)
245249
// eslint-disable-next-line react-hooks/exhaustive-deps
246-
}, [flagsmith, featureName, key, identifier, flag?.value, flag?.enabled])
250+
}, [flagsmith, featureName, key, identifier, flag?.value, flag?.enabled, flag?.variant])
247251

248252
return flag
249253
}
Lines changed: 25 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,25 @@
1+
{
2+
"identifier": "test_experiment_identity",
3+
"flags": [
4+
{
5+
"feature": {
6+
"id": 1804,
7+
"name": "hero",
8+
"type": "STANDARD"
9+
},
10+
"enabled": true,
11+
"feature_state_value": "https://s3-us-west-2.amazonaws.com/com.uppercut.hero-images/assets/0466/comps/466_03314.jpg"
12+
},
13+
{
14+
"feature": {
15+
"id": 6149,
16+
"name": "font_size",
17+
"type": "MULTIVARIATE"
18+
},
19+
"enabled": true,
20+
"feature_state_value": 16,
21+
"variant": "control"
22+
}
23+
],
24+
"traits": []
25+
}

test/events.test.ts

Lines changed: 6 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,4 @@
1-
import { getFlagsmith, environmentID, testIdentity } from './test-constants';
1+
import { getFlagsmith, environmentID, testIdentity, experimentIdentity } from './test-constants';
22
import { FLAG_EXPOSURE_EVENT } from '../event-processor';
33

44
const eventsUrl = 'https://events.test/';
@@ -101,20 +101,20 @@ describe('trackEvent', () => {
101101

102102
describe('getExperimentFlag', () => {
103103
test('returns the flag and fires one $flag_exposure when identified and source is SERVER', async () => {
104-
const { flagsmith, initConfig, mockFetch } = getFlagsmith(eventsConfig({ identity: testIdentity }));
104+
const { flagsmith, initConfig, mockFetch } = getFlagsmith(eventsConfig({ identity: experimentIdentity }));
105105
await flagsmith.init(initConfig); // fetches identity flags -> source SERVER
106106

107107
const flag = flagsmith.getExperimentFlag('font_size');
108-
expect(flag).toEqual(expect.objectContaining({ enabled: true, value: 16 }));
108+
expect(flag).toEqual(expect.objectContaining({ enabled: true, value: 16, variant: 'control' }));
109109

110110
await flagsmith.flushEvents();
111111
const events = JSON.parse(eventCalls(mockFetch)[0][1].body).events;
112112
const exposures = events.filter((e: any) => e.event === FLAG_EXPOSURE_EVENT);
113113
expect(exposures).toHaveLength(1);
114114
expect(exposures[0]).toEqual(expect.objectContaining({
115115
feature_name: 'font_size',
116-
identifier: testIdentity,
117-
value: '16',
116+
identifier: experimentIdentity,
117+
value: 'control',
118118
}));
119119
});
120120

@@ -154,7 +154,7 @@ describe('getExperimentFlag', () => {
154154
});
155155

156156
test('repeated calls collapse to a single exposure within the window', async () => {
157-
const { flagsmith, initConfig, mockFetch } = getFlagsmith(eventsConfig({ identity: testIdentity }));
157+
const { flagsmith, initConfig, mockFetch } = getFlagsmith(eventsConfig({ identity: experimentIdentity }));
158158
await flagsmith.init(initConfig);
159159

160160
flagsmith.getExperimentFlag('font_size');

test/react-events.test.tsx

Lines changed: 15 additions & 15 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,7 @@
11
import React, { FC, useState } from 'react'
22
import { render, screen, waitFor, fireEvent } from '@testing-library/react'
33
import { FlagsmithProvider, useExperiment } from '../react'
4-
import { getFlagsmith, testIdentity, getMockFetchWithValue } from './test-constants'
4+
import { getFlagsmith, testIdentity, experimentIdentity, getMockFetchWithValue } from './test-constants'
55

66
const eventsUrl = 'https://events.test/'
77

@@ -30,7 +30,7 @@ const Probe: FC<{ feature: string }> = ({ feature }) => {
3030

3131
describe('useExperiment', () => {
3232
test('fires one $flag_exposure when identified and source is SERVER', async () => {
33-
const { flagsmith, initConfig, mockFetch } = getFlagsmith(eventsConfig({ identity: testIdentity }))
33+
const { flagsmith, initConfig, mockFetch } = getFlagsmith(eventsConfig({ identity: experimentIdentity }))
3434
render(
3535
<FlagsmithProvider flagsmith={flagsmith} options={initConfig}>
3636
<Probe feature="font_size" />
@@ -48,13 +48,13 @@ describe('useExperiment', () => {
4848
expect(fired).toHaveLength(1)
4949
expect(fired[0]).toEqual(expect.objectContaining({
5050
feature_name: 'font_size',
51-
identifier: testIdentity,
52-
value: '16',
51+
identifier: experimentIdentity,
52+
value: 'control',
5353
}))
5454
})
5555

5656
test('repeated parent re-renders produce only one exposure', async () => {
57-
const { flagsmith, initConfig, mockFetch } = getFlagsmith(eventsConfig({ identity: testIdentity }))
57+
const { flagsmith, initConfig, mockFetch } = getFlagsmith(eventsConfig({ identity: experimentIdentity }))
5858

5959
const Storm: FC = () => {
6060
const [n, setN] = useState(0)
@@ -86,8 +86,8 @@ describe('useExperiment', () => {
8686
expect(exposures(mockFetch)).toHaveLength(1)
8787
})
8888

89-
test('a variant value change fires a second exposure', async () => {
90-
const { flagsmith, initConfig, mockFetch } = getFlagsmith(eventsConfig({ identity: testIdentity }))
89+
test('a variant change fires a second exposure even when the value is unchanged', async () => {
90+
const { flagsmith, initConfig, mockFetch } = getFlagsmith(eventsConfig({ identity: experimentIdentity }))
9191
render(
9292
<FlagsmithProvider flagsmith={flagsmith} options={initConfig}>
9393
<Probe feature="font_size" />
@@ -98,26 +98,26 @@ describe('useExperiment', () => {
9898
expect(JSON.parse(screen.getByTestId('exp').innerHTML)?.value).toBe(16)
9999
})
100100

101-
// Next fetch returns font_size = 20 for the identified user.
101+
// Next fetch buckets the user into a different variant with the same value.
102102
getMockFetchWithValue(mockFetch, {
103103
flags: [
104-
{ enabled: true, feature_state_value: 20, feature: { id: 6149, name: 'font_size' } },
104+
{ enabled: true, feature_state_value: 16, variant: 'large', feature: { id: 6149, name: 'font_size' } },
105105
],
106106
traits: [],
107107
})
108108
await flagsmith.getFlags()
109109

110110
await waitFor(() => {
111-
expect(JSON.parse(screen.getByTestId('exp').innerHTML)?.value).toBe(20)
111+
expect(JSON.parse(screen.getByTestId('exp').innerHTML)?.variant).toBe('large')
112112
})
113113

114114
await flagsmith.flushEvents()
115115
const values = exposures(mockFetch).map((e: any) => e.value)
116-
expect(values).toEqual(['16', '20'])
116+
expect(values).toEqual(['control', 'large'])
117117
})
118118

119119
test('fires a fresh exposure when identity changes even if the value is unchanged', async () => {
120-
const { flagsmith, initConfig, mockFetch } = getFlagsmith(eventsConfig({ identity: testIdentity }))
120+
const { flagsmith, initConfig, mockFetch } = getFlagsmith(eventsConfig({ identity: experimentIdentity }))
121121
render(
122122
<FlagsmithProvider flagsmith={flagsmith} options={initConfig}>
123123
<Probe feature="font_size" />
@@ -128,10 +128,10 @@ describe('useExperiment', () => {
128128
expect(JSON.parse(screen.getByTestId('exp').innerHTML)?.value).toBe(16)
129129
})
130130

131-
// A different identity that resolves the SAME font_size value (16).
131+
// A different identity that resolves the SAME font_size variant and value.
132132
getMockFetchWithValue(mockFetch, {
133133
flags: [
134-
{ enabled: true, feature_state_value: 16, feature: { id: 6149, name: 'font_size' } },
134+
{ enabled: true, feature_state_value: 16, variant: 'control', feature: { id: 6149, name: 'font_size' } },
135135
],
136136
traits: [],
137137
})
@@ -141,7 +141,7 @@ describe('useExperiment', () => {
141141
await flagsmith.flushEvents()
142142
expect(exposures(mockFetch)).toHaveLength(2)
143143
})
144-
expect(exposures(mockFetch).map((e: any) => e.identifier)).toEqual([testIdentity, 'other_identity'])
144+
expect(exposures(mockFetch).map((e: any) => e.identifier)).toEqual([experimentIdentity, 'other_identity'])
145145
})
146146

147147
test('renders the flag but fires no exposure when events are disabled', async () => {

test/react-variant.test.tsx

Lines changed: 51 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,51 @@
1+
import React, { FC } from 'react'
2+
import { render, screen, waitFor } from '@testing-library/react'
3+
import { FlagsmithProvider, useFlags } from '../react'
4+
import { getFlagsmith, experimentIdentity, getMockFetchWithValue } from './test-constants'
5+
6+
const FlagsPage: FC<{ flags: string[] }> = ({ flags: names }) => {
7+
const flags = useFlags(names)
8+
return <div data-testid="flags">{JSON.stringify(flags)}</div>
9+
}
10+
11+
const renderedFlags = () => JSON.parse(screen.getByTestId('flags').innerHTML)
12+
13+
describe('useFlags variant', () => {
14+
test('re-renders when only the variant changes', async () => {
15+
const { flagsmith, initConfig, mockFetch } = getFlagsmith({ identity: experimentIdentity })
16+
render(
17+
<FlagsmithProvider flagsmith={flagsmith} options={initConfig}>
18+
<FlagsPage flags={['font_size']} />
19+
</FlagsmithProvider>
20+
)
21+
22+
await waitFor(() => {
23+
expect(renderedFlags().font_size).toEqual({ enabled: true, value: 16, variant: 'control' })
24+
})
25+
26+
getMockFetchWithValue(mockFetch, {
27+
flags: [
28+
{ enabled: true, feature_state_value: 16, variant: 'large', feature: { id: 6149, name: 'font_size' } },
29+
],
30+
traits: [],
31+
})
32+
await flagsmith.getFlags()
33+
34+
await waitFor(() => {
35+
expect(renderedFlags().font_size.variant).toBe('large')
36+
})
37+
})
38+
39+
test('surfaces the variant when the flag name needs normalising', async () => {
40+
const { flagsmith, initConfig } = getFlagsmith({ identity: experimentIdentity })
41+
render(
42+
<FlagsmithProvider flagsmith={flagsmith} options={initConfig}>
43+
<FlagsPage flags={['Font Size']} />
44+
</FlagsmithProvider>
45+
)
46+
47+
await waitFor(() => {
48+
expect(renderedFlags()['Font Size']).toEqual({ enabled: true, value: 16, variant: 'control' })
49+
})
50+
})
51+
})

test/test-constants.ts

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -25,6 +25,7 @@ export const defaultState = {
2525
};
2626

2727
export const testIdentity = 'test_identity'
28+
export const experimentIdentity = 'test_experiment_identity'
2829
export const identityState = {
2930
api: 'https://edge.api.flagsmith.com/api/v1/',
3031
identity: testIdentity,
@@ -87,6 +88,8 @@ export function getFlagsmith(config: Partial<IInitConfig> = {}) {
8788
return {status: 200, text: () => fs.readFile('./test/data/flags.json', 'utf8')}
8889
case 'https://edge.api.flagsmith.com/api/v1/identities/?identifier=' + testIdentity:
8990
return {status: 200, text: () => fs.readFile(`./test/data/identities_${testIdentity}.json`, 'utf8')}
91+
case 'https://edge.api.flagsmith.com/api/v1/identities/?identifier=' + experimentIdentity:
92+
return {status: 200, text: () => fs.readFile(`./test/data/identities_${experimentIdentity}.json`, 'utf8')}
9093
}
9194

9295
throw new Error('Please mock the call to ' + url)

0 commit comments

Comments
 (0)