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
18 changes: 15 additions & 3 deletions flagsmith-core.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 ? { variant: feature.variant } : {}),
};
});
traits.forEach(trait => {
Expand Down Expand Up @@ -994,9 +995,20 @@ 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) {
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;
}
if (this.loadingState.source !== FlagSource.SERVER) {
return flag;
}
this.trackExposureEvent(featureName, { value: flag.variant });
return flag;
};

Expand Down
22 changes: 13 additions & 9 deletions react.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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(',')
Expand All @@ -89,7 +91,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() {
Expand Down Expand Up @@ -181,9 +183,11 @@ export function useFlags<F extends string | Record<string, any>, T extends strin
const res: any = {}
flags
.map((k) => {
const variant = flagsmith!.getAllFlags()?.[normalizeFlagKey(k)]?.variant
res[k] = {
enabled: flagsmith!.hasFeature(k),
value: flagsmith!.getValue(k),
...(variant != null ? { variant } : {}),
}
})
Comment thread
Zaimwa9 marked this conversation as resolved.
.concat(
Expand All @@ -204,16 +208,16 @@ export function useFlags<F extends string | Record<string, any>, 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
*/
export function useExperiment(featureName: string): IFlagsmithFeature | null {
const flagsmith = useContext(FlagsmithContext)
const key = featureName.toLowerCase().replace(/ /g, '_')
const key = normalizeFlagKey(featureName)
const lastExposureKey = useRef<string | null>(null)
const [, setRenderKey] = useState<string>(() => getExperimentRenderKey(flagsmith, key))

Expand All @@ -236,14 +240,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
}
Expand Down
25 changes: 25 additions & 0 deletions test/data/identities_test_experiment_identity.json
Original file line number Diff line number Diff line change
@@ -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": []
}
12 changes: 6 additions & 6 deletions test/events.test.ts
Original file line number Diff line number Diff line change
@@ -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/';
Expand Down Expand Up @@ -101,20 +101,20 @@ 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');
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;
const exposures = events.filter((e: any) => e.event === FLAG_EXPOSURE_EVENT);
expect(exposures).toHaveLength(1);
expect(exposures[0]).toEqual(expect.objectContaining({
feature_name: 'font_size',
identifier: testIdentity,
value: '16',
identifier: experimentIdentity,
value: 'control',
}));
});

Expand Down Expand Up @@ -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');
Expand Down
30 changes: 15 additions & 15 deletions test/react-events.test.tsx
Original file line number Diff line number Diff line change
@@ -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/'

Expand Down Expand Up @@ -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(
<FlagsmithProvider flagsmith={flagsmith} options={initConfig}>
<Probe feature="font_size" />
Expand All @@ -48,13 +48,13 @@ describe('useExperiment', () => {
expect(fired).toHaveLength(1)
expect(fired[0]).toEqual(expect.objectContaining({
feature_name: 'font_size',
identifier: testIdentity,
value: '16',
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)
Expand Down Expand Up @@ -86,8 +86,8 @@ describe('useExperiment', () => {
expect(exposures(mockFetch)).toHaveLength(1)
})

test('a variant value change fires a second exposure', async () => {
const { flagsmith, initConfig, mockFetch } = getFlagsmith(eventsConfig({ identity: testIdentity }))
test('a variant change fires a second exposure even when the value is unchanged', async () => {
const { flagsmith, initConfig, mockFetch } = getFlagsmith(eventsConfig({ identity: experimentIdentity }))
render(
<FlagsmithProvider flagsmith={flagsmith} options={initConfig}>
<Probe feature="font_size" />
Expand All @@ -98,26 +98,26 @@ 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 () => {
const { flagsmith, initConfig, mockFetch } = getFlagsmith(eventsConfig({ identity: testIdentity }))
const { flagsmith, initConfig, mockFetch } = getFlagsmith(eventsConfig({ identity: experimentIdentity }))
render(
<FlagsmithProvider flagsmith={flagsmith} options={initConfig}>
<Probe feature="font_size" />
Expand All @@ -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: [],
})
Expand All @@ -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 () => {
Expand Down
51 changes: 51 additions & 0 deletions test/react-variant.test.tsx
Original file line number Diff line number Diff line change
@@ -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 <div data-testid="flags">{JSON.stringify(flags)}</div>
}

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(
<FlagsmithProvider flagsmith={flagsmith} options={initConfig}>
<FlagsPage flags={['font_size']} />
</FlagsmithProvider>
)

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(
<FlagsmithProvider flagsmith={flagsmith} options={initConfig}>
<FlagsPage flags={['Font Size']} />
</FlagsmithProvider>
)

await waitFor(() => {
expect(renderedFlags()['Font Size']).toEqual({ enabled: true, value: 16, variant: 'control' })
})
})
})
3 changes: 3 additions & 0 deletions test/test-constants.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -87,6 +88,8 @@ export function getFlagsmith(config: Partial<IInitConfig> = {}) {
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)
Expand Down
Loading
Loading