diff --git a/src/SplitClient.tsx b/src/SplitClient.tsx new file mode 100644 index 0000000..7640ee6 --- /dev/null +++ b/src/SplitClient.tsx @@ -0,0 +1,28 @@ +import React from 'react'; +import { SplitContext } from './SplitContext'; +import { ISplitClientProps } from './types'; +import { useSplitClient } from './useSplitClient'; + +/** + * SplitClient will initialize a new SDK client and listen for its events in order to update the Split Context. + * Children components will have access to the new client when accessing Split Context. + * + * The underlying SDK client can be changed during the component lifecycle + * if the component is updated with a different splitKey prop. + * + * @deprecated `SplitClient` will be removed in a future major release. We recommend replacing it with the `useSplitClient` hook. + */ +export function SplitClient(props: ISplitClientProps) { + const { children } = props; + const context = useSplitClient(props); + + return ( + + { + typeof children === 'function' ? + children(context) : + children + } + + ) +} diff --git a/src/SplitFactoryProvider.tsx b/src/SplitFactoryProvider.tsx index 6182be4..02e36b0 100644 --- a/src/SplitFactoryProvider.tsx +++ b/src/SplitFactoryProvider.tsx @@ -2,7 +2,7 @@ import React from 'react'; import { ISplitFactoryProviderProps } from './types'; import { WARN_SF_CONFIG_AND_FACTORY } from './constants'; -import { getSplitFactory, destroySplitFactory, initAttributes } from './utils'; +import { getSplitFactory, destroySplitFactory, getSplitClient, getStatus, initAttributes } from './utils'; import { SplitContext } from './SplitContext'; /** @@ -22,6 +22,7 @@ export function SplitFactoryProvider(props: ISplitFactoryProviderProps) { initAttributes(factory && factory.client(), attributes); return factory; }, [config, propFactory, attributes]); + const client = factory ? getSplitClient(factory) : undefined; // Effect to initialize and destroy the factory when config is provided React.useEffect(() => { @@ -41,7 +42,7 @@ export function SplitFactoryProvider(props: ISplitFactoryProviderProps) { }, [config, propFactory]); return ( - + {props.children} ); diff --git a/src/SplitTreatments.tsx b/src/SplitTreatments.tsx new file mode 100644 index 0000000..1a9e2a8 --- /dev/null +++ b/src/SplitTreatments.tsx @@ -0,0 +1,25 @@ +import React from 'react'; + +import { SplitContext } from './SplitContext'; +import { ISplitTreatmentsProps } from './types'; +import { useSplitTreatments } from './useSplitTreatments'; + +/** + * SplitTreatments accepts a list of feature flag names and optional attributes. It accesses the client at SplitContext to + * call the 'client.getTreatmentsWithConfig()' method if the `names` prop is provided, or the 'client.getTreatmentsWithConfigByFlagSets()' method + * if the `flagSets` prop is provided. It then passes the resulting treatments to a child component as a function. + * + * @deprecated `SplitTreatments` will be removed in a future major release. We recommend replacing it with the `useSplitTreatments` hook. + */ +export function SplitTreatments(props: ISplitTreatmentsProps) { + const { children } = props; + const context = useSplitTreatments(props); + + return ( + + { + children(context) + } + + ); +} diff --git a/src/__tests__/SplitClient.test.tsx b/src/__tests__/SplitClient.test.tsx new file mode 100644 index 0000000..70d37e1 --- /dev/null +++ b/src/__tests__/SplitClient.test.tsx @@ -0,0 +1,614 @@ +import React from 'react'; +import { render, act } from '@testing-library/react'; + +/** Mocks and test utils */ +import { mockSdk, Event, getLastInstance } from './testUtils/mockSplitFactory'; +jest.mock('@splitsoftware/splitio/client', () => { + return { SplitFactory: mockSdk() }; +}); +import { SplitFactory } from '@splitsoftware/splitio/client'; +import { sdkBrowser } from './testUtils/sdkConfigs'; + +/** Test target */ +import { ISplitClientChildProps, ISplitFactoryChildProps } from '../types'; +import { SplitFactoryProvider } from '../SplitFactoryProvider'; +import { SplitClient } from '../SplitClient'; +import { SplitContext } from '../SplitContext'; +import { INITIAL_STATUS, testAttributesBinding, TestComponentProps } from './testUtils/utils'; +import { IClientWithContext } from '../utils'; +import { EXCEPTION_NO_SFP } from '../constants'; + +describe('SplitClient', () => { + + test('passes no-ready props to the child if client is not ready.', () => { + render( + + + {(childProps: ISplitClientChildProps) => { + expect(childProps).toEqual({ + ...INITIAL_STATUS, + factory: getLastInstance(SplitFactory), + client: getLastInstance(SplitFactory).client('user1'), + }); + + return null; + }} + + + ); + }); + + test('passes ready props to the child if client is ready.', async () => { + const outerFactory = SplitFactory(sdkBrowser); + (outerFactory as any).client().__emitter__.emit(Event.SDK_READY_FROM_CACHE); + (outerFactory as any).client().__emitter__.emit(Event.SDK_READY); + (outerFactory.manager().names as jest.Mock).mockReturnValue(['split1']); + await outerFactory.client().ready(); + + render( + + {/* Equivalent to */} + + {(childProps: ISplitClientChildProps) => { + expect(childProps).toEqual({ + ...INITIAL_STATUS, + factory: outerFactory, + client: outerFactory.client(), + isReady: true, + isReadyFromCache: true, + lastUpdate: (outerFactory.client() as IClientWithContext).__getStatus().lastUpdate + }); + + return null; + }} + + + ); + }); + + test('rerenders child on SDK_READY_TIMEDOUT, SDK_READY_FROM_CACHE, SDK_READY and SDK_UPDATE events as default behavior', async () => { + const outerFactory = SplitFactory(sdkBrowser); + (outerFactory as any).client().__emitter__.emit(Event.SDK_READY); + (outerFactory.manager().names as jest.Mock).mockReturnValue(['split1']); + + await outerFactory.client().ready(); + + let renderTimes = 0; + let previousLastUpdate = -1; + + render( + + + {({ client, isReady, isReadyFromCache, hasTimedout, isTimedout, lastUpdate }: ISplitClientChildProps) => { + const statusProps = [isReady, isReadyFromCache, hasTimedout, isTimedout]; + switch (renderTimes) { + case 0: // No ready + expect(statusProps).toStrictEqual([false, false, false, false]); + break; + case 1: // Timedout + expect(statusProps).toStrictEqual([false, false, true, true]); + break; + case 2: // Ready from cache + expect(statusProps).toStrictEqual([false, true, true, true]); + break; + case 3: // Ready + expect(statusProps).toStrictEqual([true, true, true, false]); + break; + case 4: // Updated + expect(statusProps).toStrictEqual([true, true, true, false]); + break; + default: + fail('Child must not be rerendered'); + } + expect(client).toBe(outerFactory.client('user2')); + expect(lastUpdate).toBeGreaterThan(previousLastUpdate); + renderTimes++; + previousLastUpdate = lastUpdate; + return null; + }} + + + ); + + act(() => (outerFactory as any).client('user2').__emitter__.emit(Event.SDK_READY_TIMED_OUT)); + act(() => (outerFactory as any).client('user2').__emitter__.emit(Event.SDK_READY_FROM_CACHE)); + act(() => (outerFactory as any).client('user2').__emitter__.emit(Event.SDK_READY)); + act(() => (outerFactory as any).client('user2').__emitter__.emit(Event.SDK_UPDATE)); + + expect(renderTimes).toBe(5); + }); + + test('rerenders child on SDK_READY_TIMED_OUT and SDK_UPDATE events, but not on SDK_READY.', async () => { + const outerFactory = SplitFactory(sdkBrowser); + (outerFactory as any).client().__emitter__.emit(Event.SDK_READY); + (outerFactory.manager().names as jest.Mock).mockReturnValue(['split1']); + + await outerFactory.client().ready(); + + let renderTimes = 0; + let previousLastUpdate = -1; + + render( + + + {({ client, isReady, isReadyFromCache, hasTimedout, isTimedout, lastUpdate }: ISplitClientChildProps) => { + const statusProps = [isReady, isReadyFromCache, hasTimedout, isTimedout]; + switch (renderTimes) { + case 0: // No ready + expect(statusProps).toStrictEqual([false, false, false, false]); + break; + case 1: // Timedout + expect(statusProps).toStrictEqual([false, false, true, true]); + break; + case 2: // Updated. Although `updateOnSdkReady` is false, status props must reflect the current status of the client. + expect(statusProps).toStrictEqual([true, false, true, false]); + break; + default: + fail('Child must not be rerendered'); + } + expect(client).toBe(outerFactory.client('user2')); + expect(lastUpdate).toBeGreaterThan(previousLastUpdate); + renderTimes++; + previousLastUpdate = lastUpdate; + return null; + }} + + + ); + + act(() => (outerFactory as any).client('user2').__emitter__.emit(Event.SDK_READY_TIMED_OUT)); + act(() => (outerFactory as any).client('user2').__emitter__.emit(Event.SDK_READY)); + act(() => (outerFactory as any).client('user2').__emitter__.emit(Event.SDK_UPDATE)); + expect(renderTimes).toBe(3); + }); + + test('rerenders child only on SDK_READY event, when setting updateOnSdkReadyFromCache, updateOnSdkTimedout and updateOnSdkUpdate to false.', async () => { + const outerFactory = SplitFactory(sdkBrowser); + (outerFactory as any).client().__emitter__.emit(Event.SDK_READY); + (outerFactory.manager().names as jest.Mock).mockReturnValue(['split1']); + + await outerFactory.client().ready(); + + let renderTimes = 0; + let previousLastUpdate = -1; + + render( + + + {({ client, isReady, isReadyFromCache, hasTimedout, isTimedout, lastUpdate }: ISplitClientChildProps) => { + const statusProps = [isReady, isReadyFromCache, hasTimedout, isTimedout]; + switch (renderTimes) { + case 0: // No ready + expect(statusProps).toStrictEqual([false, false, false, false]); + break; + case 1: // Ready + expect(statusProps).toStrictEqual([true, false, true, false]); // not rerendering on SDK_TIMEOUT, but hasTimedout reflects the current state + break; + default: + fail('Child must not be rerendered'); + } + expect(client).toBe(outerFactory.client('user2')); + expect(lastUpdate).toBeGreaterThan(previousLastUpdate); + renderTimes++; + previousLastUpdate = lastUpdate; + return null; + }} + + + ); + + act(() => (outerFactory as any).client('user2').__emitter__.emit(Event.SDK_READY_TIMED_OUT)); + act(() => (outerFactory as any).client('user2').__emitter__.emit(Event.SDK_READY)); + act(() => (outerFactory as any).client('user2').__emitter__.emit(Event.SDK_UPDATE)); + expect(renderTimes).toBe(2); + }); + + test('must update on SDK events between the render and commit phases', () => { + const outerFactory = SplitFactory(sdkBrowser); + let count = 0; + + render( + + + {({ client }) => { + count++; + + // side effect in the render phase + if (!(client as any).__getStatus().isReady) { + (client as any).__emitter__.emit(Event.SDK_READY); + } + + return null; + }} + + + ); + + expect(count).toEqual(2); + }); + + test('renders a passed JSX.Element with a new SplitContext value.', () => { + const outerFactory = SplitFactory(sdkBrowser); + + const Component = () => { + return ( + + {(value) => { + expect(value).toEqual({ + ...INITIAL_STATUS, + factory: outerFactory, + client: outerFactory.client('user2'), + }); + + return null; + }} + + ); + }; + + render( + + + + + + ); + }); + + test('throws error if invoked outside of SplitFactoryProvider.', () => { + expect(() => { + render( + + {() => null} + + ); + }).toThrow(EXCEPTION_NO_SFP); + }); + + test(`passes a new client if re-rendered with a different splitKey. + Only updates the state if the new client triggers an event, but not the previous one.`, (done) => { + const outerFactory = SplitFactory(sdkBrowser); + let renderTimes = 0; + + class InnerComponent extends React.Component { + + constructor(props: any) { + super(props); + this.state = { splitKey: 'user1' }; + } + + async componentDidMount() { + await act(() => this.setState({ splitKey: 'user2' })); + await act(() => (outerFactory as any).client('user2').__emitter__.emit(Event.SDK_READY_TIMED_OUT)); + await act(() => (outerFactory as any).client('user1').__emitter__.emit(Event.SDK_READY_TIMED_OUT)); + await act(() => this.setState({ splitKey: 'user3' })); + await act(() => (outerFactory as any).client('user2').__emitter__.emit(Event.SDK_READY)); + await act(() => (outerFactory as any).client('user3').__emitter__.emit(Event.SDK_READY)); + await act(() => (outerFactory as any).client('user2').__emitter__.emit(Event.SDK_UPDATE)); + await act(() => (outerFactory as any).client('user3').__emitter__.emit(Event.SDK_UPDATE)); + expect(renderTimes).toBe(6); + + done(); + } + + render() { + return ( + + {({ client, isReady, isReadyFromCache, hasTimedout, isTimedout }: ISplitClientChildProps) => { + const statusProps = [isReady, isReadyFromCache, hasTimedout, isTimedout]; + switch (renderTimes) { + case 0: + expect(client).toBe(outerFactory.client('user1')); + expect(statusProps).toStrictEqual([false, false, false, false]); + break; + case 1: + expect(client).toBe(outerFactory.client('user2')); + expect(statusProps).toStrictEqual([false, false, false, false]); + break; + case 2: + expect(client).toBe(outerFactory.client('user2')); + expect(statusProps).toStrictEqual([false, false, true, true]); + break; + case 3: + expect(client).toBe(outerFactory.client('user3')); + expect(statusProps).toStrictEqual([false, false, false, false]); + break; + case 4: + expect(client).toBe(outerFactory.client('user3')); + expect(statusProps).toStrictEqual([true, false, false, false]); + break; + case 5: + expect(client).toBe(outerFactory.client('user3')); + expect(statusProps).toStrictEqual([true, false, false, false]); + break; + default: + fail('Child must not be rerendered'); + } + renderTimes++; + return null; + }} + + ); + } + } + + render( + + + + ); + }); + + test('attributes binding test with utility', (done) => { + + function Component({ attributesFactory, attributesClient, splitKey, testSwitch, factory }: TestComponentProps) { + return ( + + + {() => { + testSwitch(done, splitKey); + return null; + }} + + + ); + } + + testAttributesBinding(Component); + }); + +}); + +// Tests to validate the migration from `SplitFactoryProvider` with child as a function in v1, to `SplitFactoryProvider` + `SplitClient` with child as a function in v2. +describe('SplitFactoryProvider + SplitClient', () => { + + test('rerenders child on SDK_READY_TIMEDOUT, SDK_READY_FROM_CACHE, SDK_READY and SDK_UPDATE events as default behavior (config prop)', async () => { + let renderTimes = 0; + let previousLastUpdate = -1; + + render( + + + {({ factory, isReady, isReadyFromCache, hasTimedout, isTimedout, lastUpdate }: ISplitFactoryChildProps) => { + const statusProps = [isReady, isReadyFromCache, hasTimedout, isTimedout]; + switch (renderTimes) { + case 0: // No ready + expect(statusProps).toStrictEqual([false, false, false, false]); + break; + case 1: // Timedout + expect(statusProps).toStrictEqual([false, false, true, true]); + break; + case 2: // Ready from cache + expect(statusProps).toStrictEqual([false, true, true, true]); + break; + case 3: // Ready + expect(statusProps).toStrictEqual([true, true, true, false]); + break; + case 4: // Updated + expect(statusProps).toStrictEqual([true, true, true, false]); + break; + default: + fail('Child must not be rerendered'); + } // eslint-disable-next-line no-use-before-define + expect(factory).toBe(getLastInstance(SplitFactory)); + expect(lastUpdate).toBeGreaterThan(previousLastUpdate); + renderTimes++; + previousLastUpdate = lastUpdate; + return null; + }} + + + ); + + const innerFactory = (SplitFactory as jest.Mock).mock.results.slice(-1)[0].value; + act(() => (innerFactory as any).client().__emitter__.emit(Event.SDK_READY_TIMED_OUT)); + act(() => (innerFactory as any).client().__emitter__.emit(Event.SDK_READY_FROM_CACHE)); + act(() => (innerFactory as any).client().__emitter__.emit(Event.SDK_READY)); + act(() => (innerFactory as any).client().__emitter__.emit(Event.SDK_UPDATE)); + + expect(renderTimes).toBe(5); + }); + + test('rerenders child on SDK_READY_TIMEDOUT, SDK_READY_FROM_CACHE, SDK_READY and SDK_UPDATE events as default behavior (factory prop)', async () => { + const outerFactory = SplitFactory(sdkBrowser); + let renderTimes = 0; + let previousLastUpdate = -1; + + render( + + + {({ factory, isReady, isReadyFromCache, hasTimedout, isTimedout, lastUpdate }: ISplitFactoryChildProps) => { + const statusProps = [isReady, isReadyFromCache, hasTimedout, isTimedout]; + switch (renderTimes) { + case 0: // No ready + expect(statusProps).toStrictEqual([false, false, false, false]); + break; + case 1: // Timedout + expect(statusProps).toStrictEqual([false, false, true, true]); + break; + case 2: // Ready from cache + expect(statusProps).toStrictEqual([false, true, true, true]); + break; + case 3: // Ready + expect(statusProps).toStrictEqual([true, true, true, false]); + break; + case 4: // Updated + expect(statusProps).toStrictEqual([true, true, true, false]); + break; + default: + fail('Child must not be rerendered'); + } + expect(factory).toBe(outerFactory); + expect(lastUpdate).toBeGreaterThan(previousLastUpdate); + renderTimes++; + previousLastUpdate = lastUpdate; + return null; + }} + + + ); + + act(() => (outerFactory as any).client().__emitter__.emit(Event.SDK_READY_TIMED_OUT)); + act(() => (outerFactory as any).client().__emitter__.emit(Event.SDK_READY_FROM_CACHE)); + act(() => (outerFactory as any).client().__emitter__.emit(Event.SDK_READY)); + act(() => (outerFactory as any).client().__emitter__.emit(Event.SDK_UPDATE)); + + expect(renderTimes).toBe(5); + }); + + test('rerenders child on SDK_READY_TIMED_OUT and SDK_UPDATE events, but not on SDK_READY (config prop)', async () => { + let renderTimes = 0; + let previousLastUpdate = -1; + + render( + + + {({ factory, isReady, isReadyFromCache, hasTimedout, isTimedout, lastUpdate }: ISplitFactoryChildProps) => { + const statusProps = [isReady, isReadyFromCache, hasTimedout, isTimedout]; + switch (renderTimes) { + case 0: // No ready + expect(statusProps).toStrictEqual([false, false, false, false]); + break; + case 1: // Timedout + expect(statusProps).toStrictEqual([false, false, true, true]); + break; + case 2: // Updated. Although `updateOnSdkReady` is false, status props must reflect the current status of the client. + expect(statusProps).toStrictEqual([true, false, true, false]); + break; + default: + fail('Child must not be rerendered'); + } // eslint-disable-next-line no-use-before-define + expect(factory).toBe(getLastInstance(SplitFactory)); + expect(lastUpdate).toBeGreaterThan(previousLastUpdate); + renderTimes++; + previousLastUpdate = lastUpdate; + return null; + }} + + + ); + + const innerFactory = (SplitFactory as jest.Mock).mock.results.slice(-1)[0].value; + act(() => (innerFactory as any).client().__emitter__.emit(Event.SDK_READY_TIMED_OUT)); + act(() => (innerFactory as any).client().__emitter__.emit(Event.SDK_READY)); + act(() => (innerFactory as any).client().__emitter__.emit(Event.SDK_UPDATE)); + + expect(renderTimes).toBe(3); + }); + + test('rerenders child on SDK_READY_TIMED_OUT and SDK_UPDATE events, but not on SDK_READY (factory prop)', async () => { + const outerFactory = SplitFactory(sdkBrowser); + let renderTimes = 0; + let previousLastUpdate = -1; + + render( + + + {({ factory, isReady, isReadyFromCache, hasTimedout, isTimedout, lastUpdate }: ISplitFactoryChildProps) => { + const statusProps = [isReady, isReadyFromCache, hasTimedout, isTimedout]; + switch (renderTimes) { + case 0: // No ready + expect(statusProps).toStrictEqual([false, false, false, false]); + break; + case 1: // Timedout + expect(statusProps).toStrictEqual([false, false, true, true]); + break; + case 2: // Updated. Although `updateOnSdkReady` is false, status props must reflect the current status of the client. + expect(statusProps).toStrictEqual([true, false, true, false]); + break; + default: + fail('Child must not be rerendered'); + } + expect(factory).toBe(outerFactory); + expect(lastUpdate).toBeGreaterThan(previousLastUpdate); + renderTimes++; + previousLastUpdate = lastUpdate; + return null; + }} + + + ); + + act(() => (outerFactory as any).client().__emitter__.emit(Event.SDK_READY_TIMED_OUT)); + act(() => (outerFactory as any).client().__emitter__.emit(Event.SDK_READY)); + act(() => (outerFactory as any).client().__emitter__.emit(Event.SDK_UPDATE)); + + expect(renderTimes).toBe(3); + }); + + test('rerenders child only on SDK_READY and SDK_READY_FROM_CACHE event (config prop)', async () => { + let renderTimes = 0; + let previousLastUpdate = -1; + + render( + + + {({ factory, isReady, isReadyFromCache, hasTimedout, isTimedout, lastUpdate }: ISplitFactoryChildProps) => { + const statusProps = [isReady, isReadyFromCache, hasTimedout, isTimedout]; + switch (renderTimes) { + case 0: // No ready + expect(statusProps).toStrictEqual([false, false, false, false]); + break; + case 1: // Ready + expect(statusProps).toStrictEqual([true, false, true, false]); // not rerendering on SDK_TIMEOUT, but hasTimedout reflects the current state + break; + default: + fail('Child must not be rerendered'); + } // eslint-disable-next-line no-use-before-define + expect(factory).toBe(getLastInstance(SplitFactory)); + expect(lastUpdate).toBeGreaterThan(previousLastUpdate); + renderTimes++; + previousLastUpdate = lastUpdate; + return null; + }} + + + ); + + const innerFactory = (SplitFactory as jest.Mock).mock.results.slice(-1)[0].value; + act(() => (innerFactory as any).client().__emitter__.emit(Event.SDK_READY_TIMED_OUT)); + act(() => (innerFactory as any).client().__emitter__.emit(Event.SDK_READY)); + act(() => (innerFactory as any).client().__emitter__.emit(Event.SDK_UPDATE)); + expect(renderTimes).toBe(2); + }); + + test('rerenders child only on SDK_READY and SDK_READY_FROM_CACHE event (factory prop)', async () => { + const outerFactory = SplitFactory(sdkBrowser); + let renderTimes = 0; + let previousLastUpdate = -1; + + render( + + + {({ factory, isReady, isReadyFromCache, hasTimedout, isTimedout, lastUpdate }: ISplitFactoryChildProps) => { + const statusProps = [isReady, isReadyFromCache, hasTimedout, isTimedout]; + switch (renderTimes) { + case 0: // No ready + expect(statusProps).toStrictEqual([false, false, false, false]); + break; + case 1: // Ready + expect(statusProps).toStrictEqual([true, false, true, false]); // not rerendering on SDK_TIMEOUT, but hasTimedout reflects the current state + break; + default: + fail('Child must not be rerendered'); + } + expect(factory).toBe(outerFactory); + expect(lastUpdate).toBeGreaterThan(previousLastUpdate); + renderTimes++; + previousLastUpdate = lastUpdate; + return null; + }} + + + ); + + act(() => (outerFactory as any).client().__emitter__.emit(Event.SDK_READY_TIMED_OUT)); + act(() => (outerFactory as any).client().__emitter__.emit(Event.SDK_READY)); + act(() => (outerFactory as any).client().__emitter__.emit(Event.SDK_UPDATE)); + expect(renderTimes).toBe(2); + }); + +}); diff --git a/src/__tests__/SplitContext.test.tsx b/src/__tests__/SplitContext.test.tsx index 73fce28..5388adb 100644 --- a/src/__tests__/SplitContext.test.tsx +++ b/src/__tests__/SplitContext.test.tsx @@ -2,6 +2,7 @@ import React from 'react'; import { render } from '@testing-library/react'; import { SplitContext } from '../SplitContext'; import { SplitFactoryProvider } from '../SplitFactoryProvider'; +import { INITIAL_STATUS } from './testUtils/utils'; /** * Test default SplitContext value @@ -22,7 +23,11 @@ test('SplitContext.Consumer shows value when wrapped in a SplitFactoryProvider', {(value) => { - expect(value).toEqual({ factory: undefined }); + expect(value).toEqual({ + ...INITIAL_STATUS, + factory: undefined, + client: undefined + }); return null; }} diff --git a/src/__tests__/SplitFactoryProvider.test.tsx b/src/__tests__/SplitFactoryProvider.test.tsx index 496011c..f4ab7f9 100644 --- a/src/__tests__/SplitFactoryProvider.test.tsx +++ b/src/__tests__/SplitFactoryProvider.test.tsx @@ -25,8 +25,11 @@ describe('SplitFactoryProvider', () => { {React.createElement(() => { const context = useSplitContext(); - - expect(context).toEqual({ factory: getLastInstance(SplitFactory) }); + expect(context).toEqual({ + ...INITIAL_STATUS, + factory: getLastInstance(SplitFactory), + client: getLastInstance(SplitFactory).client(), + }); return null; })} @@ -64,7 +67,11 @@ describe('SplitFactoryProvider', () => { return ( {(value) => { - expect(value).toEqual({ factory: getLastInstance(SplitFactory) }); + expect(value).toEqual({ + ...INITIAL_STATUS, + factory: getLastInstance(SplitFactory), + client: getLastInstance(SplitFactory).client(), + }); done(); return null; }} diff --git a/src/__tests__/SplitTreatments.test.tsx b/src/__tests__/SplitTreatments.test.tsx new file mode 100644 index 0000000..b94b477 --- /dev/null +++ b/src/__tests__/SplitTreatments.test.tsx @@ -0,0 +1,433 @@ +import React from 'react'; +import { render, RenderResult, act } from '@testing-library/react'; + +/** Mocks */ +import { mockSdk, Event } from './testUtils/mockSplitFactory'; +jest.mock('@splitsoftware/splitio/client', () => { + return { SplitFactory: mockSdk() }; +}); +import { SplitFactory } from '@splitsoftware/splitio/client'; +import { sdkBrowser } from './testUtils/sdkConfigs'; +import { getStatus, IClientWithContext } from '../utils'; +import { newSplitFactoryLocalhostInstance } from './testUtils/utils'; +import { CONTROL_WITH_CONFIG, EXCEPTION_NO_SFP } from '../constants'; + +/** Test target */ +import { ISplitTreatmentsChildProps, ISplitTreatmentsProps, ISplitClientProps } from '../types'; +import { SplitTreatments } from '../SplitTreatments'; +import { SplitClient } from '../SplitClient'; +import { SplitFactoryProvider } from '../SplitFactoryProvider'; +import { useSplitTreatments } from '../useSplitTreatments'; + +const logSpy = jest.spyOn(console, 'log'); + +describe('SplitTreatments', () => { + + const featureFlagNames = ['split1', 'split2']; + const flagSets = ['set1', 'set2']; + + afterEach(() => { logSpy.mockClear() }); + + it('passes control treatments (empty object if flagSets is provided) if the SDK is not operational.', () => { + render( + + + {() => { + return ( +
+ + {({ treatments }: ISplitTreatmentsChildProps) => { + expect(treatments).toEqual({ split1: CONTROL_WITH_CONFIG, split2: CONTROL_WITH_CONFIG }); + return null; + }} + + + {({ treatments }: ISplitTreatmentsChildProps) => { + expect(treatments).toEqual({}); + return null; + }} + +
+ ); + }} +
+
+ ); + }); + + it('passes as treatments prop the value returned by the method "client.getTreatmentsWithConfig(ByFlagSets)" if the SDK is ready.', () => { + const outerFactory = SplitFactory(sdkBrowser); + (outerFactory as any).client().__emitter__.emit(Event.SDK_READY); + + render( + + + {({ factory, isReady }) => { + expect(getStatus(outerFactory.client()).isReady).toBe(isReady); + expect(isReady).toBe(true); + return ( + <> + + {({ treatments, isReady: isReady2, isReadyFromCache, hasTimedout, isTimedout, isDestroyed, lastUpdate }: ISplitTreatmentsChildProps) => { + const clientMock: any = factory?.client(); + expect(clientMock.getTreatmentsWithConfig.mock.calls.length).toBe(1); + expect(treatments).toBe(clientMock.getTreatmentsWithConfig.mock.results[0].value); + expect(featureFlagNames).toBe(clientMock.getTreatmentsWithConfig.mock.calls[0][0]); + expect([isReady2, isReadyFromCache, hasTimedout, isTimedout, isDestroyed, lastUpdate]).toStrictEqual([true, false, false, false, false, (outerFactory.client() as IClientWithContext).__getStatus().lastUpdate]); + return null; + }} + + + {({ treatments }: ISplitTreatmentsChildProps) => { + const clientMock: any = factory?.client(); + expect(clientMock.getTreatmentsWithConfigByFlagSets.mock.calls.length).toBe(1); + expect(treatments).toBe(clientMock.getTreatmentsWithConfigByFlagSets.mock.results[0].value); + expect(flagSets).toBe(clientMock.getTreatmentsWithConfigByFlagSets.mock.calls[0][0]); + return null; + }} + + + ); + }} + + + ); + }); + + test('throws error if invoked outside of SplitFactoryProvider.', () => { + expect(() => { + render( + + {() => null} + + ); + }).toThrow(EXCEPTION_NO_SFP); + }); + + /** + * Input validation. Passing invalid feature flag names or attributes while the Sdk + * is not ready doesn't emit errors, and logs meaningful messages instead. + */ + it('Input validation: invalid "names" and "attributes" props in SplitTreatments.', (done) => { + render( + + + {() => { + return ( + <> + {/* @ts-expect-error Test error handling */} + + {({ treatments }: ISplitTreatmentsChildProps) => { + expect(treatments).toEqual({}); + return null; + }} + + {/* @ts-expect-error Test error handling */} + + {({ treatments }: ISplitTreatmentsChildProps) => { + expect(treatments).toEqual({}); + return null; + }} + + {/* @ts-expect-error Test error handling */} + + {({ treatments }: ISplitTreatmentsChildProps) => { + expect(treatments).toEqual({}); + return null; + }} + + + ); + }} + + + ); + expect(logSpy).toBeCalledWith('[ERROR] feature flag names must be a non-empty array.'); + expect(logSpy).toBeCalledWith('[ERROR] you passed an invalid feature flag name, feature flag name must be a non-empty string.'); + + done(); + }); + + + test('ignores flagSets and logs a warning if both names and flagSets params are provided.', () => { + render( + + {/* @ts-expect-error flagSets and names are mutually exclusive */} + + {({ treatments }) => { + expect(treatments).toEqual({ split1: CONTROL_WITH_CONFIG, split2: CONTROL_WITH_CONFIG }); + return null; + }} + + + ); + + expect(logSpy).toBeCalledWith('[WARN] Both names and flagSets properties were provided. flagSets will be ignored.'); + }); + + test('returns the treatments from the client at Split context updated by SplitClient, or control if the client is not operational.', async () => { + const outerFactory = SplitFactory(sdkBrowser); + const client: any = outerFactory.client('user2'); + const attributes = { att1: 'att1' }; + let treatments: SplitIO.TreatmentsWithConfig; + + render( + + + {React.createElement(() => { + treatments = useSplitTreatments({ names: featureFlagNames, attributes }).treatments; + return null; + })} + + + ); + + // returns control treatment if not operational (SDK not ready or destroyed), without calling `getTreatmentsWithConfig` method + expect(client.getTreatmentsWithConfig).not.toBeCalled(); + expect(treatments!).toEqual({ split1: CONTROL_WITH_CONFIG, split2: CONTROL_WITH_CONFIG }); + + // once operational (SDK_READY_FROM_CACHE), it evaluates feature flags + act(() => client.__emitter__.emit(Event.SDK_READY_FROM_CACHE)); + + expect(client.getTreatmentsWithConfig).toBeCalledWith(featureFlagNames, attributes); + expect(client.getTreatmentsWithConfig).toHaveReturnedWith(treatments); + }); +}); + +let renderTimes = 0; + +/** + * Tests for asserting that client.getTreatmentsWithConfig and client.getTreatmentsWithConfigByFlagSets are not called unnecessarily when using SplitTreatments and useSplitTreatments. + */ +describe.each([ + ({ names, flagSets, attributes }: { names?: string[], flagSets?: string[], attributes?: SplitIO.Attributes }) => ( + // @ts-expect-error names and flagSets are mutually exclusive + + {() => { + renderTimes++; + return null; + }} + + ), + ({ names, flagSets, attributes }: { names?: string[], flagSets?: string[], attributes?: SplitIO.Attributes }) => { + // @ts-expect-error names and flagSets are mutually exclusive + useSplitTreatments({ names, flagSets, attributes }); + renderTimes++; + return null; + } +])('SplitTreatments & useSplitTreatments optimization', (InnerComponent) => { + let outerFactory = SplitFactory(sdkBrowser); + (outerFactory as any).client().__emitter__.emit(Event.SDK_READY); + + function Component({ names, flagSets, attributes, splitKey, clientAttributes }: { + names?: ISplitTreatmentsProps['names'] + flagSets?: ISplitTreatmentsProps['flagSets'] + attributes: ISplitTreatmentsProps['attributes'] + splitKey: ISplitClientProps['splitKey'] + clientAttributes?: ISplitClientProps['attributes'] + }) { + return ( + + + + + + ); + } + + const names = ['split1', 'split2']; + const flagSets = ['set1', 'set2']; + const attributes = { att1: 'att1' }; + const splitKey = sdkBrowser.core.key; + + let wrapper: RenderResult; + + beforeEach(() => { + renderTimes = 0; + (outerFactory.client().getTreatmentsWithConfig as jest.Mock).mockClear(); + wrapper = render(); + }) + + afterEach(() => { + wrapper.unmount(); // unmount to remove event listener from factory + }) + + it('rerenders but does not re-evaluate feature flags if client, lastUpdate, names, flagSets and attributes are the same object.', () => { + wrapper.rerender(); + + expect(renderTimes).toBe(2); + expect(outerFactory.client().getTreatmentsWithConfig).toBeCalledTimes(1); + expect(outerFactory.client().getTreatmentsWithConfigByFlagSets).toBeCalledTimes(0); + }); + + it('rerenders but does not re-evaluate feature flags if client, lastUpdate, names, flagSets and attributes are equals (shallow comparison).', () => { + wrapper.rerender(); + + expect(renderTimes).toBe(2); + expect(outerFactory.client().getTreatmentsWithConfig).toBeCalledTimes(1); + expect(outerFactory.client().getTreatmentsWithConfigByFlagSets).toBeCalledTimes(0); + }); + + it('rerenders and re-evaluates feature flags if names are not equals (shallow array comparison).', () => { + wrapper.rerender(); + + expect(renderTimes).toBe(2); + expect(outerFactory.client().getTreatmentsWithConfig).toBeCalledTimes(2); + expect(outerFactory.client().getTreatmentsWithConfigByFlagSets).toBeCalledTimes(0); + }); + + it('rerenders and re-evaluates feature flags if flag sets are not equals (shallow array comparison).', () => { + wrapper.rerender(); + wrapper.rerender(); + expect(outerFactory.client().getTreatmentsWithConfigByFlagSets).toBeCalledTimes(1); + + wrapper.rerender(); + + expect(renderTimes).toBe(4); + expect(outerFactory.client().getTreatmentsWithConfig).toBeCalledTimes(1); + expect(outerFactory.client().getTreatmentsWithConfigByFlagSets).toBeCalledTimes(2); + }); + + it('rerenders and re-evaluates feature flags if attributes are not equals (shallow object comparison).', () => { + const attributesRef = { ...attributes, att2: 'att2' }; + wrapper.rerender(); + + expect(renderTimes).toBe(2); + expect(outerFactory.client().getTreatmentsWithConfig).toBeCalledTimes(2); + + // If passing same reference but mutated (bad practice), the component re-renders but doesn't re-evaluate feature flags + attributesRef.att2 = 'att2_val2'; + wrapper.rerender(); + expect(renderTimes).toBe(3); + expect(outerFactory.client().getTreatmentsWithConfig).toBeCalledTimes(2); + }); + + it('rerenders and re-evaluates feature flags if lastUpdate timestamp changes (e.g., SDK_UPDATE event).', () => { + expect(renderTimes).toBe(1); + + // State update and split evaluation + act(() => (outerFactory as any).client().__emitter__.emit(Event.SDK_UPDATE)); + + // State update after destroy doesn't re-evaluate because the sdk is not operational + (outerFactory as any).client().destroy(); + wrapper.rerender(); + + // Updates were batched as a single render, due to automatic batching https://reactjs.org/blog/2022/03/29/react-v18.html#new-feature-automatic-batching + expect(renderTimes).toBe(3); + expect(outerFactory.client().getTreatmentsWithConfig).toBeCalledTimes(2); + + // Restore the client to be READY + (outerFactory as any).client().__restore(); + (outerFactory as any).client().__emitter__.emit(Event.SDK_READY); + }); + + it('rerenders and re-evaluates feature flags if client changes.', async () => { + wrapper.rerender(); + await act(() => (outerFactory as any).client('otherKey').__emitter__.emit(Event.SDK_READY)); + + // Initial render + 2 renders (in 3 updates) -> automatic batching https://reactjs.org/blog/2022/03/29/react-v18.html#new-feature-automatic-batching + expect(renderTimes).toBe(3); + expect(outerFactory.client().getTreatmentsWithConfig).toBeCalledTimes(1); + expect(outerFactory.client('otherKey').getTreatmentsWithConfig).toBeCalledTimes(1); + }); + + it('rerenders and re-evaluate feature flags when Split context changes (in both SplitFactoryProvider and SplitClient components).', async () => { + // changes in SplitContext implies that either the factory, the client (user key), or its status changed, what might imply a change in treatments + const outerFactory = SplitFactory(sdkBrowser); + let renderTimesComp1 = 0; + let renderTimesComp2 = 0; + + // test context updates on SplitFactoryProvider + render( + + + {() => { + renderTimesComp1++; + return null; + }} + + + ); + + // test context updates on SplitClient + render( + + + + {() => { + renderTimesComp2++; + return null; + }} + + + + ); + + expect(renderTimesComp1).toBe(1); + expect(renderTimesComp2).toBe(1); + + act(() => { + (outerFactory as any).client().__emitter__.emit(Event.SDK_READY_FROM_CACHE); + (outerFactory as any).client('user2').__emitter__.emit(Event.SDK_READY_FROM_CACHE); + }); + + expect(renderTimesComp1).toBe(2); + expect(renderTimesComp2).toBe(2); // updateOnSdkReadyFromCache === false, in second component + + act(() => { + (outerFactory as any).client().__emitter__.emit(Event.SDK_READY_TIMED_OUT); + (outerFactory as any).client('user2').__emitter__.emit(Event.SDK_READY_TIMED_OUT); + }); + + expect(renderTimesComp1).toBe(3); + expect(renderTimesComp2).toBe(3); + + act(() => { + (outerFactory as any).client().__emitter__.emit(Event.SDK_READY); + (outerFactory as any).client('user2').__emitter__.emit(Event.SDK_READY); + }); + + expect(renderTimesComp1).toBe(3); // updateOnSdkReady === false, in first component + expect(renderTimesComp2).toBe(4); + + act(() => { + (outerFactory as any).client().__emitter__.emit(Event.SDK_UPDATE); + (outerFactory as any).client('user2').__emitter__.emit(Event.SDK_UPDATE); + }); + + expect(renderTimesComp1).toBe(4); + expect(renderTimesComp2).toBe(5); + expect(outerFactory.client().getTreatmentsWithConfig).toBeCalledTimes(3); // renderTimes - 1, for the 1st render where SDK is not operational + expect(outerFactory.client('user2').getTreatmentsWithConfig).toBeCalledTimes(4); // idem + }); + + it('rerenders and re-evaluates feature flags if client attributes changes.', (done) => { + const originalFactory = outerFactory; + outerFactory = newSplitFactoryLocalhostInstance(); + + const client = outerFactory.client('emma2'); + const clientSpy = { + getTreatmentsWithConfig: jest.spyOn(client, 'getTreatmentsWithConfig') + } + + client.on(client.Event.SDK_READY, () => { + wrapper = render(); + expect(clientSpy.getTreatmentsWithConfig).toBeCalledTimes(1); + wrapper.rerender(); + expect(renderTimes).toBe(3); + expect(clientSpy.getTreatmentsWithConfig).toBeCalledTimes(2); + + wrapper.rerender(); + expect(renderTimes).toBe(4); + expect(clientSpy.getTreatmentsWithConfig).toBeCalledTimes(3); + + wrapper.rerender(); + expect(renderTimes).toBe(5); + expect(clientSpy.getTreatmentsWithConfig).toBeCalledTimes(3); // not called again. clientAttributes object is shallow equal + + outerFactory = originalFactory; + client.destroy().then(done) + }) + }); + +}); diff --git a/src/__tests__/index.test.ts b/src/__tests__/index.test.ts index 6e5c20c..199610c 100644 --- a/src/__tests__/index.test.ts +++ b/src/__tests__/index.test.ts @@ -3,23 +3,37 @@ import { SplitContext as ExportedSplitContext, SplitFactory as ExportedSplitFactory, SplitFactoryProvider as ExportedSplitFactoryProvider, + SplitClient as ExportedSplitClient, + SplitTreatments as ExportedSplitTreatments, + withSplitFactory as exportedWithSplitFactory, + withSplitClient as exportedWithSplitClient, + withSplitTreatments as exportedWithSplitTreatments, useTrack as exportedUseTrack, useSplitClient as exportedUseSplitClient, useSplitTreatments as exportedUseSplitTreatments, useSplitManager as exportedUseSplitManager, // Checks that types are exported. Otherwise, the test would fail with a TS error. GetTreatmentsOptions, + ISplitClientChildProps, + ISplitClientProps, ISplitContextValues, ISplitFactoryProviderProps, ISplitStatus, - IUseSplitTreatmentsResult, + ISplitTreatmentsChildProps, + ISplitTreatmentsProps, IUpdateProps, IUseSplitClientOptions, IUseSplitTreatmentsOptions, + IUseSplitManagerResult } from '../index'; import { SplitContext } from '../SplitContext'; import { SplitFactory } from '@splitsoftware/splitio/client'; import { SplitFactoryProvider } from '../SplitFactoryProvider'; +import { SplitClient } from '../SplitClient'; +import { SplitTreatments } from '../SplitTreatments'; +import { withSplitFactory } from '../withSplitFactory'; +import { withSplitClient } from '../withSplitClient'; +import { withSplitTreatments } from '../withSplitTreatments'; import { useTrack } from '../useTrack'; import { useSplitClient } from '../useSplitClient'; import { useSplitTreatments } from '../useSplitTreatments'; @@ -29,6 +43,14 @@ describe('index', () => { it('should export components', () => { expect(ExportedSplitFactoryProvider).toBe(SplitFactoryProvider); + expect(ExportedSplitClient).toBe(SplitClient); + expect(ExportedSplitTreatments).toBe(SplitTreatments); + }); + + it('should export HOCs', () => { + expect(exportedWithSplitFactory).toBe(withSplitFactory); + expect(exportedWithSplitClient).toBe(withSplitClient); + expect(exportedWithSplitTreatments).toBe(withSplitTreatments); }); it('should export hooks', () => { diff --git a/src/__tests__/useSplitManager.test.tsx b/src/__tests__/useSplitManager.test.tsx index 97fe026..887bb2e 100644 --- a/src/__tests__/useSplitManager.test.tsx +++ b/src/__tests__/useSplitManager.test.tsx @@ -18,7 +18,7 @@ import { INITIAL_STATUS } from './testUtils/utils'; describe('useSplitManager', () => { - test('returns the factory manager from the Split context, and updates when the context changes.', () => { + test('returns the factory manager from the Split context, and updates on SDK events.', () => { const outerFactory = SplitFactory(sdkBrowser); let hookResult; render( diff --git a/src/__tests__/useSplitTreatments.test.tsx b/src/__tests__/useSplitTreatments.test.tsx index 72323b0..06b57b2 100644 --- a/src/__tests__/useSplitTreatments.test.tsx +++ b/src/__tests__/useSplitTreatments.test.tsx @@ -14,7 +14,7 @@ import { CONTROL_WITH_CONFIG, EXCEPTION_NO_SFP } from '../constants'; import { SplitFactoryProvider } from '../SplitFactoryProvider'; import { useSplitTreatments } from '../useSplitTreatments'; import { SplitContext } from '../SplitContext'; -import { IUseSplitTreatmentsResult } from '../types'; +import { ISplitTreatmentsChildProps } from '../types'; const logSpy = jest.spyOn(console, 'log'); @@ -145,7 +145,7 @@ describe('useSplitTreatments', () => { const lastUpdateSetUser2 = new Set(); const lastUpdateSetUser2WithUpdate = new Set(); - function validateTreatments({ treatments, isReady, isReadyFromCache }: IUseSplitTreatmentsResult) { + function validateTreatments({ treatments, isReady, isReadyFromCache }: ISplitTreatmentsChildProps) { if (isReady || isReadyFromCache) { expect(treatments).toEqual({ split_test: { diff --git a/src/__tests__/withSplitClient.test.tsx b/src/__tests__/withSplitClient.test.tsx new file mode 100644 index 0000000..d818389 --- /dev/null +++ b/src/__tests__/withSplitClient.test.tsx @@ -0,0 +1,110 @@ +import React from 'react'; +import { render } from '@testing-library/react'; + +/** Mocks */ +import { mockSdk, Event } from './testUtils/mockSplitFactory'; +jest.mock('@splitsoftware/splitio/client', () => { + return { SplitFactory: mockSdk() }; +}); +import { SplitFactory as SplitSdk } from '@splitsoftware/splitio/client'; +import { sdkBrowser } from './testUtils/sdkConfigs'; +import * as SplitClient from '../SplitClient'; +const SplitClientSpy = jest.spyOn(SplitClient, 'SplitClient'); +import { testAttributesBinding, TestComponentProps } from './testUtils/utils'; + +/** Test target */ +import { withSplitFactory } from '../withSplitFactory'; +import { withSplitClient } from '../withSplitClient'; + +describe('withSplitClient', () => { + + test('passes no-ready props to the child if client is not ready.', () => { + const Component = withSplitFactory(sdkBrowser)( + withSplitClient('user1')( + ({ client, isReady, isReadyFromCache, hasTimedout, isTimedout, isDestroyed, lastUpdate }) => { + expect(client).not.toBe(null); + expect([isReady, isReadyFromCache, hasTimedout, isTimedout, isDestroyed, lastUpdate]).toStrictEqual([false, false, false, false, false, 0]); + return null; + } + ) + ); + render(); + }); + + test('passes ready props to the child if client is ready.', (done) => { + const outerFactory = SplitSdk(sdkBrowser); + (outerFactory as any).client().__emitter__.emit(Event.SDK_READY); + (outerFactory.manager().names as jest.Mock).mockReturnValue(['split1']); + outerFactory.client().ready().then(() => { + const Component = withSplitFactory(undefined, outerFactory)( + withSplitClient('user1')( + ({ client, isReady, isReadyFromCache, hasTimedout, isTimedout, isDestroyed, lastUpdate }) => { + expect(client).toBe(outerFactory.client('user1')); + expect([isReady, isReadyFromCache, hasTimedout, isTimedout, isDestroyed, lastUpdate]).toStrictEqual([false, false, false, false, false, 0]); + return null; + } + ) + ); + render(); + done(); + }); + }); + + test('passes Split props and outer props to the child.', () => { + const Component = withSplitFactory(sdkBrowser)<{ outerProp1: string, outerProp2: number }>( + withSplitClient('user1')<{ outerProp1: string, outerProp2: number }>( + ({ outerProp1, outerProp2, client, isReady, isReadyFromCache, hasTimedout, isTimedout, isDestroyed, lastUpdate }) => { + expect(outerProp1).toBe('outerProp1'); + expect(outerProp2).toBe(2); + expect(client).not.toBe(null); + expect([isReady, isReadyFromCache, hasTimedout, isTimedout, isDestroyed, lastUpdate]).toStrictEqual([false, false, false, false, false, 0]); + return null; + } + ) + ); + render(); + }); + + test('passes Status props to SplitClient.', () => { + const updateOnSdkUpdate = true; + const updateOnSdkTimedout = false; + const updateOnSdkReady = true; + const updateOnSdkReadyFromCache = false; + const Component = withSplitFactory(sdkBrowser)( + withSplitClient('user1')<{ outerProp1: string, outerProp2: number }>( + () => null, updateOnSdkUpdate, updateOnSdkTimedout, updateOnSdkReady, updateOnSdkReadyFromCache + ) + ); + render(); + + expect(SplitClientSpy).toHaveBeenLastCalledWith( + expect.objectContaining({ + updateOnSdkUpdate, + updateOnSdkTimedout, + updateOnSdkReady, + updateOnSdkReadyFromCache, + }), + expect.anything() + ); + }); + + test('attributes binding test with utility', (done) => { + + function Component({ attributesFactory, attributesClient, splitKey, testSwitch, factory }: TestComponentProps) { + const FactoryComponent = withSplitFactory(undefined, factory, attributesFactory)<{ attributesClient: SplitIO.Attributes, splitKey: any }>( + ({ attributesClient, splitKey }) => { + const ClientComponent = withSplitClient(splitKey, attributesClient)( + () => { + testSwitch(done, splitKey); + return null; + }) + return ; + } + ) + return + } + + testAttributesBinding(Component); + }); + +}); diff --git a/src/__tests__/withSplitFactory.test.tsx b/src/__tests__/withSplitFactory.test.tsx new file mode 100644 index 0000000..98e765a --- /dev/null +++ b/src/__tests__/withSplitFactory.test.tsx @@ -0,0 +1,83 @@ +import React from 'react'; +import { render } from '@testing-library/react'; + +/** Mocks */ +import { mockSdk, Event } from './testUtils/mockSplitFactory'; +jest.mock('@splitsoftware/splitio/client', () => { + return { SplitFactory: mockSdk() }; +}); +import { SplitFactory } from '@splitsoftware/splitio/client'; +import { sdkBrowser } from './testUtils/sdkConfigs'; +import { SplitClient } from '../SplitClient'; +jest.mock('../SplitClient'); + +/** Test target */ +import { ISplitFactoryChildProps } from '../types'; +import { withSplitFactory } from '../withSplitFactory'; + +describe('withSplitFactory', () => { + + test('passes no-ready props to the child if initialized with a no ready factory (e.g., using config object).', () => { + const Component = withSplitFactory(sdkBrowser)( + ({ factory, isReady, isReadyFromCache, hasTimedout, isTimedout, isDestroyed, lastUpdate }: ISplitFactoryChildProps) => { + expect(factory).toBeInstanceOf(Object); + expect([isReady, isReadyFromCache, hasTimedout, isTimedout, isDestroyed, lastUpdate]).toStrictEqual([false, false, false, false, false, 0]); + return null; + } + ); + render(); + }); + + test('passes ready props to the child if initialized with a ready factory.', (done) => { + const outerFactory = SplitFactory(sdkBrowser); + (outerFactory as any).client().__emitter__.emit(Event.SDK_READY); + (outerFactory.manager().names as jest.Mock).mockReturnValue(['split1']); + outerFactory.client().ready().then(() => { + const Component = withSplitFactory(undefined, outerFactory)( + ({ factory, isReady, isReadyFromCache, hasTimedout, isTimedout, isDestroyed, lastUpdate }: ISplitFactoryChildProps) => { + expect(factory).toBe(outerFactory); + expect([isReady, isReadyFromCache, hasTimedout, isTimedout, isDestroyed, lastUpdate]).toStrictEqual([true, false, false, false, false, 0]); + return null; + } + ); + render(); + done(); + }); + }); + + test('passes Split props and outer props to the child.', () => { + const Component = withSplitFactory(sdkBrowser)<{ outerProp1: string, outerProp2: number }>( + ({ outerProp1, outerProp2, factory, isReady, isReadyFromCache, hasTimedout, isTimedout, isDestroyed, lastUpdate }) => { + expect(outerProp1).toBe('outerProp1'); + expect(outerProp2).toBe(2); + expect(factory).toBeInstanceOf(Object); + expect([isReady, isReadyFromCache, hasTimedout, isTimedout, isDestroyed, lastUpdate]).toStrictEqual([false, false, false, false, false, 0]); + return null; + } + ); + render(); + }); + + test('passes Status props to SplitFactory.', () => { + const updateOnSdkUpdate = true; + const updateOnSdkTimedout = false; + const updateOnSdkReady = true; + const updateOnSdkReadyFromCache = false; + const Component = withSplitFactory(sdkBrowser)<{ outerProp1: string, outerProp2: number }>( + () => null, updateOnSdkUpdate, updateOnSdkTimedout, updateOnSdkReady, updateOnSdkReadyFromCache + ); + + render(); + + expect(SplitClient).toHaveBeenLastCalledWith( + expect.objectContaining({ + updateOnSdkUpdate, + updateOnSdkTimedout, + updateOnSdkReady, + updateOnSdkReadyFromCache + }), + expect.anything(), + ); + }); + +}); diff --git a/src/__tests__/withSplitTreatments.test.tsx b/src/__tests__/withSplitTreatments.test.tsx new file mode 100644 index 0000000..b4f5d2e --- /dev/null +++ b/src/__tests__/withSplitTreatments.test.tsx @@ -0,0 +1,54 @@ +import React from 'react'; +import { render } from '@testing-library/react'; + +/** Mocks */ +import { mockSdk } from './testUtils/mockSplitFactory'; +jest.mock('@splitsoftware/splitio/client', () => { + return { SplitFactory: mockSdk() }; +}); +import { sdkBrowser } from './testUtils/sdkConfigs'; + +/** Test target */ +import { withSplitFactory } from '../withSplitFactory'; +import { withSplitClient } from '../withSplitClient'; +import { withSplitTreatments } from '../withSplitTreatments'; +import { getControlTreatmentsWithConfig } from '../utils'; + +describe('withSplitTreatments', () => { + + it(`passes Split props and outer props to the child. + In this test, the value of "props.treatments" is obtained by the function "getControlTreatmentsWithConfig", + and not "client.getTreatmentsWithConfig" since the client is not ready.`, () => { + const featureFlagNames = ['split1', 'split2']; + + const Component = withSplitFactory(sdkBrowser)<{ outerProp1: string, outerProp2: number }>( + ({ outerProp1, outerProp2, factory }) => { + const SubComponent = withSplitClient('user1')<{ outerProp1: string, outerProp2: number }>( + withSplitTreatments(featureFlagNames)( + (props) => { + const clientMock = factory!.client('user1'); + expect((clientMock.getTreatmentsWithConfig as jest.Mock).mock.calls.length).toBe(0); + + expect(props).toStrictEqual({ + factory: factory, client: clientMock, + outerProp1: 'outerProp1', outerProp2: 2, + treatments: getControlTreatmentsWithConfig(featureFlagNames), + isReady: false, + isReadyFromCache: false, + hasTimedout: false, + isTimedout: false, + isDestroyed: false, + lastUpdate: 0 + }); + + return null; + } + ) + ); + return ; + }); + + render(); + }); + +}); diff --git a/src/index.ts b/src/index.ts index 202e5ec..431fe6e 100644 --- a/src/index.ts +++ b/src/index.ts @@ -1,7 +1,14 @@ // Split SDK factory (Renamed to avoid name conflict with SplitFactory component) export { SplitFactory } from '@splitsoftware/splitio/client'; +// HOC functions +export { withSplitFactory } from './withSplitFactory'; +export { withSplitClient } from './withSplitClient'; +export { withSplitTreatments } from './withSplitTreatments'; + // Components +export { SplitTreatments } from './SplitTreatments'; +export { SplitClient } from './SplitClient'; export { SplitFactoryProvider } from './SplitFactoryProvider'; // Hooks @@ -16,11 +23,16 @@ export { SplitContext } from './SplitContext'; // Types export type { GetTreatmentsOptions, + ISplitClientChildProps, + ISplitClientProps, ISplitContextValues, + ISplitFactoryChildProps, ISplitFactoryProviderProps, ISplitStatus, - IUseSplitTreatmentsResult, + ISplitTreatmentsChildProps, + ISplitTreatmentsProps, IUpdateProps, IUseSplitClientOptions, - IUseSplitTreatmentsOptions + IUseSplitTreatmentsOptions, + IUseSplitManagerResult } from './types'; diff --git a/src/types.ts b/src/types.ts index 0b6ba39..cebbab2 100644 --- a/src/types.ts +++ b/src/types.ts @@ -42,7 +42,7 @@ export interface ISplitStatus { /** * Split Context Value interface. It is used to define the value types of Split Context */ -export interface ISplitContextValues { +export interface ISplitContextValues extends ISplitStatus { /** * Split factory instance. @@ -53,6 +53,17 @@ export interface ISplitContextValues { * @see {@link https://help.split.io/hc/en-us/articles/360020448791-JavaScript-SDK#user-consent} */ factory?: SplitIO.IBrowserSDK; + + /** + * Split client instance. + * + * NOTE: This property is not recommended for direct use, as better alternatives are available: + * - `useSplitTreatments` hook to evaluate feature flags. + * - `useTrack` hook to track events. + * + * @see {@link https://help.split.io/hc/en-us/articles/360020448791-JavaScript-SDK#2-instantiate-the-sdk-and-create-a-new-split-client} + */ + client?: SplitIO.IBrowserClient; } /** @@ -62,31 +73,39 @@ export interface IUpdateProps { /** * `updateOnSdkUpdate` indicates if the component will update (i.e., re-render) in case of an `SDK_UPDATE` event. - * If `true`, components consuming the context (such as `SplitClient` and `SplitTreatments`) will re-render on SDK_UPDATE. + * If `true`, components consuming the context (such as `SplitClient` and `SplitTreatments`) will re-render on `SDK_UPDATE`. * It's value is `true` by default. */ updateOnSdkUpdate?: boolean; /** * `updateOnSdkTimedout` indicates if the component will update (i.e., re-render) in case of a `SDK_READY_TIMED_OUT` event. + * If `true`, components consuming the context (such as `SplitClient` and `SplitTreatments`) will re-render on `SDK_READY_TIMED_OUT`. * It's value is `true` by default. */ updateOnSdkTimedout?: boolean; /** * `updateOnSdkReady` indicates if the component will update (i.e., re-render) in case of a `SDK_READY` event. + * If `true`, components consuming the context (such as `SplitClient` and `SplitTreatments`) will re-render on `SDK_READY`. * It's value is `true` by default. */ updateOnSdkReady?: boolean; /** * `updateOnSdkReadyFromCache` indicates if the component will update (i.e., re-render) in case of a `SDK_READY_FROM_CACHE` event. + * If `true`, components consuming the context (such as `SplitClient` and `SplitTreatments`) will re-render on `SDK_READY_FROM_CACHE`. * This params is only relevant when using 'LOCALSTORAGE' as storage type, since otherwise the event is never emitted. * It's value is `true` by default. */ updateOnSdkReadyFromCache?: boolean; } +/** + * SplitFactoryProvider Child Props interface. These are the props that the child component receives from the 'withSplitFactory' HOC. + */ +export interface ISplitFactoryChildProps extends ISplitContextValues { } + /** * SplitFactoryProvider Props interface. These are the props accepted by the `SplitFactoryProvider` component, * used to instantiate a factory instance and provide it to the Split Context. @@ -127,26 +146,29 @@ export interface IUseSplitClientOptions extends IUpdateProps { splitKey?: SplitIO.SplitKey; /** - * An object of type Attributes used to evaluate feature flags. + * An object of type Attributes used to evaluate the feature flags. */ attributes?: SplitIO.Attributes; } -export interface IUseSplitClientResult extends ISplitContextValues, ISplitStatus { +/** + * SplitClient Child Props interface. These are the props that the child as a function receives from the 'SplitClient' component. + */ +export interface ISplitClientChildProps extends ISplitContextValues { } + +/** + * SplitClient Props interface. These are the props accepted by SplitClient component, + * used to instantiate a new client instance, update the Split context, and listen for SDK events. + */ +export interface ISplitClientProps extends IUseSplitClientOptions { /** - * Split client instance. - * - * NOTE: This property is not recommended for direct use, as better alternatives are available: - * - `useSplitTreatments` hook to evaluate feature flags. - * - `useTrack` hook to track events. - * - * @see {@link https://help.split.io/hc/en-us/articles/360020448791-JavaScript-SDK#2-instantiate-the-sdk-and-create-a-new-split-client} + * Children of the SplitClient component. It can be a functional component (child as a function) or a React element. */ - client?: SplitIO.IBrowserClient; + children: ((props: ISplitClientChildProps) => ReactNode) | ReactNode; } -export interface IUseSplitManagerResult extends ISplitContextValues, ISplitStatus { +export interface IUseSplitManagerResult extends ISplitContextValues { /** * Split manager instance. * @@ -184,9 +206,9 @@ export type GetTreatmentsOptions = ({ export type IUseSplitTreatmentsOptions = GetTreatmentsOptions & IUseSplitClientOptions; /** - * useSplitTreatments hook result. + * SplitTreatments Child Props interface. These are the props that the child component receives from the 'SplitTreatments' component. */ -export interface IUseSplitTreatmentsResult extends IUseSplitClientResult { +export interface ISplitTreatmentsChildProps extends ISplitContextValues { /** * An object with the treatments with configs for a bulk of feature flags, returned by client.getTreatmentsWithConfig(). @@ -201,3 +223,15 @@ export interface IUseSplitTreatmentsResult extends IUseSplitClientResult { */ treatments: SplitIO.TreatmentsWithConfig; } + +/** + * SplitTreatments Props interface. These are the props accepted by SplitTreatments component, used to call 'client.getTreatmentsWithConfig()', or 'client.getTreatmentsWithConfigByFlagSets()', + * depending on whether `names` or `flagSets` props are provided, and to pass the result to the child component. + */ +export type ISplitTreatmentsProps = IUseSplitTreatmentsOptions & { + + /** + * Children of the SplitTreatments component. It must be a functional component (child as a function) you want to show. + */ + children: ((props: ISplitTreatmentsChildProps) => ReactNode); +} diff --git a/src/useSplitClient.ts b/src/useSplitClient.ts index 7dce7fe..9b66d7c 100644 --- a/src/useSplitClient.ts +++ b/src/useSplitClient.ts @@ -1,7 +1,7 @@ import React from 'react'; import { useSplitContext } from './SplitContext'; -import { getSplitClient, initAttributes, getStatus } from './utils'; -import { IUseSplitClientResult, IUseSplitClientOptions } from './types'; +import { getSplitClient, initAttributes, IClientWithContext, getStatus } from './utils'; +import { ISplitContextValues, IUseSplitClientOptions } from './types'; export const DEFAULT_UPDATE_OPTIONS = { updateOnSdkUpdate: true, @@ -18,20 +18,22 @@ export const DEFAULT_UPDATE_OPTIONS = { * * @example * ```js - * const { client, isReady, isReadyFromCache, lastUpdate, ... } = useSplitClient({ splitKey: 'user_id' }); + * const { factory, client, isReady, isReadyFromCache, hasTimedout, lastUpdate } = useSplitClient({ splitKey: 'user_id' }); * ``` * * @see {@link https://help.split.io/hc/en-us/articles/360020448791-JavaScript-SDK#advanced-instantiate-multiple-sdk-clients} */ -export function useSplitClient(options?: IUseSplitClientOptions): IUseSplitClientResult { +export function useSplitClient(options?: IUseSplitClientOptions): ISplitContextValues { const { updateOnSdkReady, updateOnSdkReadyFromCache, updateOnSdkTimedout, updateOnSdkUpdate, splitKey, attributes } = { ...DEFAULT_UPDATE_OPTIONS, ...options }; - const { factory } = useSplitContext(); + const context = useSplitContext(); + const { client: contextClient, factory } = context; - // @TODO `getSplitClient` starts client sync. Move side effects to useEffect - const client = factory ? getSplitClient(factory, splitKey) : undefined; + // @TODO Move `getSplitClient` side effects + // @TODO Once `SplitClient` is removed, which updates the context, simplify next line as `const client = factory ? getSplitClient(factory, splitKey) : undefined;` + const client = factory && splitKey ? getSplitClient(factory, splitKey) : contextClient as IClientWithContext; initAttributes(client, attributes); diff --git a/src/useSplitTreatments.ts b/src/useSplitTreatments.ts index 33a1ff1..3cb32da 100644 --- a/src/useSplitTreatments.ts +++ b/src/useSplitTreatments.ts @@ -1,6 +1,6 @@ import React from 'react'; import { memoizeGetTreatmentsWithConfig } from './utils'; -import { IUseSplitTreatmentsResult, IUseSplitTreatmentsOptions } from './types'; +import { ISplitTreatmentsChildProps, IUseSplitTreatmentsOptions } from './types'; import { useSplitClient } from './useSplitClient'; /** @@ -13,12 +13,12 @@ import { useSplitClient } from './useSplitClient'; * * @example * ```js - * const { treatments: { feature_1, feature_2 }, isReady, isReadyFromCache, lastUpdate, ... } = useSplitTreatments({ names: ['feature_1', 'feature_2']}); + * const { treatments: { feature_1, feature_2 }, isReady, isReadyFromCache, hasTimedout, lastUpdate, ... } = useSplitTreatments({ names: ['feature_1', 'feature_2']}); * ``` * * @see {@link https://help.split.io/hc/en-us/articles/360020448791-JavaScript-SDK#get-treatments-with-configurations} */ -export function useSplitTreatments(options: IUseSplitTreatmentsOptions): IUseSplitTreatmentsResult { +export function useSplitTreatments(options: IUseSplitTreatmentsOptions): ISplitTreatmentsChildProps { const context = useSplitClient({ ...options, attributes: undefined }); const { client, lastUpdate } = context; const { names, flagSets, attributes } = options; diff --git a/src/withSplitClient.tsx b/src/withSplitClient.tsx new file mode 100644 index 0000000..3528b98 --- /dev/null +++ b/src/withSplitClient.tsx @@ -0,0 +1,44 @@ +import React from 'react'; +import { ISplitClientChildProps } from './types'; +import { SplitClient } from './SplitClient'; + +/** + * High-Order Component for SplitClient. + * The wrapped component receives all the props of the container, + * along with the passed props from SplitClient (see ISplitClientChildProps). + * + * @param splitKey - The customer identifier. + * @param attributes - An object of type Attributes used to evaluate the feature flags. + * + * @deprecated `withSplitClient` will be removed in a future major release. We recommend replacing it with the `useSplitClient` hook. + */ +export function withSplitClient(splitKey: SplitIO.SplitKey, attributes?: SplitIO.Attributes) { + + return function withSplitClientHoc( + WrappedComponent: React.ComponentType, + updateOnSdkUpdate = false, + updateOnSdkTimedout = false, + updateOnSdkReady = true, + updateOnSdkReadyFromCache = true, + ) { + + return function wrapper(props: OuterProps) { + return ( + + {(splitProps) => { + return ( + + ); + }} + + ); + }; + }; +} diff --git a/src/withSplitFactory.tsx b/src/withSplitFactory.tsx new file mode 100644 index 0000000..ec1df96 --- /dev/null +++ b/src/withSplitFactory.tsx @@ -0,0 +1,50 @@ +import React from 'react'; +import { ISplitFactoryChildProps } from './types'; +import { SplitFactoryProvider } from './SplitFactoryProvider'; +import { SplitClient } from './SplitClient'; + +/** + * High-Order Component for SplitFactoryProvider. + * The wrapped component receives all the props of the container, + * along with the passed props from the Split context (see `ISplitFactoryChildProps`). + * + * @param config - Config object used to instantiate a Split factory + * @param factory - Split factory instance to use instead of creating a new one with the config object. + * @param attributes - An object of type Attributes used to evaluate the feature flags. + * + * @deprecated `withSplitFactory` will be removed in a future major release. We recommend replacing it with the `SplitFactoryProvider` component. + */ +export function withSplitFactory(config?: SplitIO.IBrowserSettings, factory?: SplitIO.IBrowserSDK, attributes?: SplitIO.Attributes) { + + return function withSplitFactoryHoc( + WrappedComponent: React.ComponentType, + updateOnSdkUpdate = false, + updateOnSdkTimedout = false, + updateOnSdkReady = true, + updateOnSdkReadyFromCache = true, + ) { + + return function wrapper(props: OuterProps) { + return ( + + + + {(splitProps) => { + return ( + + ); + }} + + + ); + }; + }; +} diff --git a/src/withSplitTreatments.tsx b/src/withSplitTreatments.tsx new file mode 100644 index 0000000..be452b8 --- /dev/null +++ b/src/withSplitTreatments.tsx @@ -0,0 +1,36 @@ +import React from 'react'; +import { ISplitTreatmentsChildProps } from './types'; +import { SplitTreatments } from './SplitTreatments'; + +/** + * High-Order Component for SplitTreatments. + * The wrapped component receives all the props of the container, + * along with the passed props from SplitTreatments (see ISplitTreatmentsChildProps). + * + * @param names - list of feature flag names + * @param attributes - An object of type Attributes used to evaluate the feature flags. + * + * @deprecated `withSplitTreatments` will be removed in a future major release. We recommend replacing it with the `useSplitTreatments` hook. + */ +export function withSplitTreatments(names: string[], attributes?: SplitIO.Attributes) { + + return function withSplitTreatmentsHoc( + WrappedComponent: React.ComponentType, + ) { + + return function wrapper(props: OuterProps) { + return ( + + {(splitProps) => { + return ( + + ); + }} + + ); + }; + }; +}