Skip to content
Open
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
6 changes: 6 additions & 0 deletions react.d.ts
Original file line number Diff line number Diff line change
Expand Up @@ -30,3 +30,9 @@ export declare function useFlags<
export declare const useFlagsmith: <F extends string | Record<string, any>,
T extends string = string>() => IFlagsmith<F, T>;
export declare const useFlagsmithLoading: () => LoadingState | undefined;
export declare function useExperiment(flagKey: string): {
enabled: boolean;
value: IFlagsmithValue;
success: () => Promise<void>;
failure: () => Promise<void>;
};
37 changes: 37 additions & 0 deletions react.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -198,3 +198,40 @@ export function useFlagsmith<F extends string | Record<string, any>, T extends s

return context as unknown as IFlagsmith<F, T>
}

export function useExperiment(flagKey: string) {
const flagsmith = useFlagsmith()
const flags = useFlags([flagKey])
const enrolledRef = useRef(false)
const reportedRef = useRef(false)

// Auto-enroll: set variant trait when user first sees the experiment
useEffect(() => {
if (!enrolledRef.current && flags[flagKey]?.enabled) {
enrolledRef.current = true
const variant = flagsmith.getValue(flagKey)
flagsmith.setTrait(`exp_${flagKey}_variant`, String(variant))
}
}, [flagsmith, flagKey, flags])

const reportOutcome = useCallback(
(result: boolean) => {
if (reportedRef.current) {
return Promise.resolve()
}
reportedRef.current = true
return flagsmith.setTrait(`exp_${flagKey}_converted`, result)
},
[flagsmith, flagKey],
)

const success = useCallback(() => reportOutcome(true), [reportOutcome])
const failure = useCallback(() => reportOutcome(false), [reportOutcome])

return {
enabled: flags[flagKey]?.enabled ?? false,
value: flags[flagKey]?.value ?? null,
success,
failure,
}
}
144 changes: 142 additions & 2 deletions test/react.test.tsx
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
import React, { FC } from 'react'
import { render, screen, waitFor } from '@testing-library/react'
import { FlagsmithProvider, useFlags, useFlagsmithLoading } from '../lib/flagsmith/react'
import { fireEvent, render, screen, waitFor } from '@testing-library/react'
import { FlagsmithProvider, useFlags, useFlagsmithLoading, useExperiment } from '../lib/flagsmith/react'
import {
defaultState,
delay,
Expand Down Expand Up @@ -315,3 +315,143 @@ it('should not throw unhandled promise rejection when server returns 500 error',
expect(onError).toHaveBeenCalledTimes(1)
window.removeEventListener('unhandledrejection', unhandledRejectionHandler)
})

describe('useExperiment', () => {
const ExperimentPage: FC<{ flagKey: string }> = ({ flagKey }) => {
const { enabled, value, success, failure } = useExperiment(flagKey)
return (
<>
<div data-testid="enabled">{String(enabled)}</div>
<div data-testid="value">{String(value)}</div>
<button data-testid="success" onClick={() => success()} />
<button data-testid="failure" onClick={() => failure()} />
</>
)
}

it('reflects flag enabled and value', async () => {
const { flagsmith, initConfig } = getFlagsmith({})
jest.spyOn(flagsmith, 'setTrait').mockResolvedValue()
render(
<FlagsmithProvider flagsmith={flagsmith} options={initConfig}>
<ExperimentPage flagKey="font_size" />
</FlagsmithProvider>
)

await waitFor(() => {
expect(screen.getByTestId('enabled').textContent).toBe('true')
expect(screen.getByTestId('value').textContent).toBe('16')
})
})

it('returns enabled=false and value=null for unknown flag', () => {
const { flagsmith, initConfig } = getFlagsmith({ preventFetch: true })
render(
<FlagsmithProvider flagsmith={flagsmith} options={initConfig}>
<ExperimentPage flagKey="nonexistent" />
</FlagsmithProvider>
)

expect(screen.getByTestId('enabled').textContent).toBe('false')
expect(screen.getByTestId('value').textContent).toBe('null')
})

it('auto-enrolls by setting variant trait on render when enabled', async () => {
const { flagsmith, initConfig } = getFlagsmith({})
const setTraitSpy = jest.spyOn(flagsmith, 'setTrait').mockResolvedValue()
render(
<FlagsmithProvider flagsmith={flagsmith} options={initConfig}>
<ExperimentPage flagKey="font_size" />
</FlagsmithProvider>
)

await waitFor(() => {
expect(screen.getByTestId('enabled').textContent).toBe('true')
})

await waitFor(() => {
expect(setTraitSpy).toHaveBeenCalledWith('exp_font_size_variant', '16')
})
})

it('does not auto-enroll when flag is disabled', () => {
const { flagsmith, initConfig } = getFlagsmith({ preventFetch: true })
const setTraitSpy = jest.spyOn(flagsmith, 'setTrait').mockResolvedValue()
render(
<FlagsmithProvider flagsmith={flagsmith} options={initConfig}>
<ExperimentPage flagKey="nonexistent" />
</FlagsmithProvider>
)

expect(setTraitSpy).not.toHaveBeenCalled()
})

it('success() calls setTrait with converted=true', async () => {
const { flagsmith, initConfig } = getFlagsmith({})
const setTraitSpy = jest.spyOn(flagsmith, 'setTrait').mockResolvedValue()
render(
<FlagsmithProvider flagsmith={flagsmith} options={initConfig}>
<ExperimentPage flagKey="font_size" />
</FlagsmithProvider>
)

await waitFor(() => {
expect(screen.getByTestId('enabled').textContent).toBe('true')
})

fireEvent.click(screen.getByTestId('success'))

await waitFor(() => {
expect(setTraitSpy).toHaveBeenCalledWith('exp_font_size_converted', true)
})
})

it('failure() calls setTrait with converted=false', async () => {
const { flagsmith, initConfig } = getFlagsmith({})
const setTraitSpy = jest.spyOn(flagsmith, 'setTrait').mockResolvedValue()
render(
<FlagsmithProvider flagsmith={flagsmith} options={initConfig}>
<ExperimentPage flagKey="font_size" />
</FlagsmithProvider>
)

await waitFor(() => {
expect(screen.getByTestId('enabled').textContent).toBe('true')
})

fireEvent.click(screen.getByTestId('failure'))

await waitFor(() => {
expect(setTraitSpy).toHaveBeenCalledWith('exp_font_size_converted', false)
})
})

it('does not report outcome twice on repeated calls', async () => {
const { flagsmith, initConfig } = getFlagsmith({})
const setTraitSpy = jest.spyOn(flagsmith, 'setTrait').mockResolvedValue()
render(
<FlagsmithProvider flagsmith={flagsmith} options={initConfig}>
<ExperimentPage flagKey="font_size" />
</FlagsmithProvider>
)

await waitFor(() => {
expect(screen.getByTestId('enabled').textContent).toBe('true')
})

// Wait for auto-enrollment to complete
await waitFor(() => {
expect(setTraitSpy).toHaveBeenCalledWith('exp_font_size_variant', '16')
})
setTraitSpy.mockClear()

fireEvent.click(screen.getByTestId('success'))
fireEvent.click(screen.getByTestId('success'))
fireEvent.click(screen.getByTestId('failure'))

await waitFor(() => {
expect(setTraitSpy).toHaveBeenCalledTimes(1)
expect(setTraitSpy).toHaveBeenCalledWith('exp_font_size_converted', true)
})
})
})