diff --git a/CHANGES.txt b/CHANGES.txt index 1827849..3be7e1c 100644 --- a/CHANGES.txt +++ b/CHANGES.txt @@ -1,3 +1,10 @@ +2.6.0 (November 4, 2025) + - Added `useTreatment`, `useTreatments`, `useTreatmentWithConfig` and `useTreatmentsWithConfig` hooks to replace the now deprecated `useSplitTreatments` hook. + - Updated @splitsoftware/splitio package to version 11.8.0 that includes minor updates: + - Added new configuration for Fallback Treatments, which allows setting a treatment value and optional config to be returned in place of "control", either globally or by flag. Read more in our docs. + - Added support for custom loggers: added `logger` configuration option and `factory.Logger.setLogger` method to allow the SDK to use a custom logger. + - Updated the SDK_READY_FROM_CACHE event to be emitted alongside the SDK_READY event if it hasn’t already been emitted. + 2.5.0 (September 18, 2025) - Updated @splitsoftware/splitio package to version 11.6.0 that includes minor updates: - Added `storage.wrapper` configuration option to allow the SDK to use a custom storage wrapper for the storage type `LOCALSTORAGE`. Default value is `window.localStorage`. diff --git a/MIGRATION-GUIDE.md b/MIGRATION-GUIDE.md index 1faac7d..2236d48 100644 --- a/MIGRATION-GUIDE.md +++ b/MIGRATION-GUIDE.md @@ -3,9 +3,32 @@ React SDK v2.0.0 has a few breaking changes that you should consider when migrating from a previous version. The main changes are: -### • Deprecated `useClient`, `useTreatments`, and `useManager` hooks have been removed. -Follow [this section](#migrating-to-get-react-sdk-v1100-improvements-replacing-the-deprecated-useclient-usetreatments-and-usemanager-hooks) to migrate to the new hooks `useSplitClient`, `useSplitTreatments`, and `useSplitManager`. +### • `useTreatments` hook was removed in v2.0.0, but re-introduced in v2.6.0 with a different API: + +Since v2.6.0, there are 4 hooks variants to evaluate feature flags, to better cover the different evaluation methods available in the JavaScript SDK client: + +- `useTreatment`: returns a treatment value for a given feature flag name. It calls `client.getTreatment()` method under the hood. +- `useTreatmentWithConfig`: returns a treatment value and its configuration for a given feature flag name. It calls `client.getTreatmentWithConfig()` method under the hood. +- `useTreatments`: returns an object with treatment values for multiple feature flag names. It calls `client.getTreatments()` or `client.getTreatmentsByFlagSets()` methods under the hood, depending if the `names` or `flagSets` option is provided. +- `useTreatmentsWithConfig`: returns an object with treatment values and their configurations for multiple feature flag names. It calls `client.getTreatmentsWithConfig()` or `client.getTreatmentsWithConfigByFlagSets()` methods under the hood, depending if the `names` or `flagSets` option is provided. + +The `useTreatments` hook from v1.x.x should be replaced with `useTreatmentsWithConfig`, as follows: + +```javascript +// v1.x.x +const treatments = useTreatments(featureFlagNames, optionalAttributes, optionalSplitKey); + +// v2.6.0+ +const { treatments } = useTreatmentsWithConfig({ names: featureFlagNames, attributes: optionalAttributes, splitKey: optionalSplitKey }); + +// v2.0.0-v2.5.0 +const { treatments } = useSplitTreatments({ names: featureFlagNames, attributes: optionalAttributes, splitKey: optionalSplitKey }); +``` + +### • Deprecated `useClient` and `useManager` hooks have been removed. + +Follow [this section](#migrating-to-get-react-sdk-v1100-improvements-replacing-the-deprecated-useclient-usetreatments-and-usemanager-hooks) to migrate to the new hooks `useSplitClient` and `useSplitManager`. ### • Updated the default value of `updateOnSdkUpdate` and `updateOnSdkTimedout` options to `true`. @@ -15,7 +38,7 @@ Consider setting the `updateOnSdkUpdate` option to `false` to revert to the prev The same applies for the equivalent props in the `[with]SplitClient` and `[with]SplitTreatments` components, although these components are deprecated and we recommend [migrating to their hook alternatives](#-high-order-components-withsplitclient-withsplittreatments-and-components-that-accept-a-render-function-as-child-component-splittreatments-and-splitclient-have-been-deprecated-and-might-be-removed-in-a-future-major-release). -### • Deprecated `SplitFactory` provider has been removed, `withSplitFactory` is deprecated, and `SplitFactoryProvider` doesn't accept `updateOn` props and a render function as children anymore. +### • Deprecated `SplitFactory` provider has been removed, `withSplitFactory` is deprecated, and `SplitFactoryProvider` doesn't accept a render function as children anymore. To migrate your existing code to the new version of `SplitFactoryProvider`, consider the following refactor example: @@ -53,21 +76,21 @@ should be refactored to: ```tsx const MyComponent = () => { - const props: ISplitContextValues = useSplitClient({ updateOnSdkUpdate: false }); + const props: ISplitContextValues = useSplitClient(); const { factory, client, isReady, isReadyFromCache, ... } = props; ... }; const App = () => { return ( - + ); }; ``` -Notice that `MyComponent` was refactored to use the `useSplitClient` hook and is passed as a React JSX element rather than a render function. The `useSplitClient` hook is called without providing a `splitKey` param. This means that the default client (whose key is set in the `core.key` property of the `mySplitConfig` object) will be used, and the `updateOnSdkUpdate` and `attributes` props are passed as options to the hook. +Notice that `MyComponent` was refactored to use the `useSplitClient` hook and is passed as a React JSX element rather than a render function. The `useSplitClient` hook is called without providing a `splitKey` param. This means that the default client (whose key is set in the `core.key` property of the `mySplitConfig` object) will be used. ### • High-Order-Components (`withSplitClient`, `withSplitTreatments`) and components that accept a render function as child component (`SplitTreatments`, and `SplitClient`) have been deprecated and might be removed in a future major release. diff --git a/README.md b/README.md index 30aec4c..987df64 100644 --- a/README.md +++ b/README.md @@ -18,7 +18,7 @@ Below is a simple example that describes the instantiation and most basic usage import React from 'react'; // Import SDK functions -import { SplitFactoryProvider, useSplitTreatments } from '@splitsoftware/splitio-react'; +import { SplitFactoryProvider, useTreatment } from '@splitsoftware/splitio-react'; // Define your config object const CONFIG = { @@ -29,18 +29,18 @@ const CONFIG = { }; function MyComponent() { - // Evaluate feature flags with useSplitTreatments hook - const { treatments: { FEATURE_FLAG_NAME }, isReady } = useSplitTreatments({ names: ['FEATURE_FLAG_NAME'] }); + // Evaluate a feature flag with useTreatment hook + const { treatment, isReady } = useTreatment({ name: 'FEATURE_FLAG_NAME' }); // Check SDK readiness using isReady prop if (!isReady) return
Loading SDK ...
; - if (FEATURE_FLAG_NAME.treatment === 'on') { - // return JSX for on treatment - } else if (FEATURE_FLAG_NAME.treatment === 'off') { - // return JSX for off treatment + if (treatment === 'on') { + // return JSX for 'on' treatment + } else if (treatment === 'off') { + // return JSX for 'off' treatment } else { - // return JSX for control treatment + // return JSX for 'control' treatment }; } diff --git a/package-lock.json b/package-lock.json index d2c6e9f..d27eccf 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,15 +1,15 @@ { "name": "@splitsoftware/splitio-react", - "version": "2.5.0", + "version": "2.6.0", "lockfileVersion": 2, "requires": true, "packages": { "": { "name": "@splitsoftware/splitio-react", - "version": "2.5.0", + "version": "2.6.0", "license": "Apache-2.0", "dependencies": { - "@splitsoftware/splitio": "11.6.0", + "@splitsoftware/splitio": "11.8.0", "memoize-one": "^5.1.1", "shallowequal": "^1.1.0", "tslib": "^2.3.1" @@ -778,9 +778,9 @@ "dev": true }, "node_modules/@ioredis/commands": { - "version": "1.4.0", - "resolved": "https://registry.npmjs.org/@ioredis/commands/-/commands-1.4.0.tgz", - "integrity": "sha512-aFT2yemJJo+TZCmieA7qnYGQooOS7QfNmYrzGtsYd3g9j5iDP8AimYYAesf79ohjbLG12XxC4nG5DyEnC88AsQ==", + "version": "1.5.0", + "resolved": "https://registry.npmjs.org/@ioredis/commands/-/commands-1.5.0.tgz", + "integrity": "sha512-eUgLqrMf8nJkZxT24JvVRrQya1vZkQh8BBeYNwGDqa5I0VUi8ACx7uFvAaLxintokpTenkK6DASvo/bvNbBGow==", "license": "MIT" }, "node_modules/@istanbuljs/load-nyc-config": { @@ -1583,12 +1583,12 @@ } }, "node_modules/@splitsoftware/splitio": { - "version": "11.6.0", - "resolved": "https://registry.npmjs.org/@splitsoftware/splitio/-/splitio-11.6.0.tgz", - "integrity": "sha512-48sksG00073Nltma/BxpH6xHVZmoBjank40EU4h+XqrMGm0qM3jGngPO9R/iWAHdSduUWAoMJVJYA68AtvKgeQ==", + "version": "11.8.0", + "resolved": "https://registry.npmjs.org/@splitsoftware/splitio/-/splitio-11.8.0.tgz", + "integrity": "sha512-M9ENeH+IEmxwELeCdXgnTbLg+ZP3SRUMM6lImSbv7mD32u1v6ihnUhnhsTJzlQWMDC4H94EAW345q1cO7ovlTQ==", "license": "Apache-2.0", "dependencies": { - "@splitsoftware/splitio-commons": "2.6.0", + "@splitsoftware/splitio-commons": "2.8.0", "bloom-filters": "^3.0.4", "ioredis": "^4.28.0", "js-yaml": "^3.13.1", @@ -1601,9 +1601,9 @@ } }, "node_modules/@splitsoftware/splitio-commons": { - "version": "2.6.0", - "resolved": "https://registry.npmjs.org/@splitsoftware/splitio-commons/-/splitio-commons-2.6.0.tgz", - "integrity": "sha512-0xODXLciIvHSuMlb8eukIB2epb3ZyGOsrwS0cMuTdxEvCqr7Nuc9pWDdJtRuN1UwL/jIjBnpDYAc8s6mpqLX2g==", + "version": "2.8.0", + "resolved": "https://registry.npmjs.org/@splitsoftware/splitio-commons/-/splitio-commons-2.8.0.tgz", + "integrity": "sha512-QgHUreMOEDwf4GZzVPu4AzkZJvuaeSoHsiJc4tT3CxSIYl2bKMz1SSDlI1tW/oVbIFeWjkrIp2lCYEyUBgcvyA==", "license": "Apache-2.0", "dependencies": { "@types/ioredis": "^4.28.0", @@ -11463,9 +11463,9 @@ "dev": true }, "@ioredis/commands": { - "version": "1.4.0", - "resolved": "https://registry.npmjs.org/@ioredis/commands/-/commands-1.4.0.tgz", - "integrity": "sha512-aFT2yemJJo+TZCmieA7qnYGQooOS7QfNmYrzGtsYd3g9j5iDP8AimYYAesf79ohjbLG12XxC4nG5DyEnC88AsQ==" + "version": "1.5.0", + "resolved": "https://registry.npmjs.org/@ioredis/commands/-/commands-1.5.0.tgz", + "integrity": "sha512-eUgLqrMf8nJkZxT24JvVRrQya1vZkQh8BBeYNwGDqa5I0VUi8ACx7uFvAaLxintokpTenkK6DASvo/bvNbBGow==" }, "@istanbuljs/load-nyc-config": { "version": "1.1.0", @@ -12095,11 +12095,11 @@ } }, "@splitsoftware/splitio": { - "version": "11.6.0", - "resolved": "https://registry.npmjs.org/@splitsoftware/splitio/-/splitio-11.6.0.tgz", - "integrity": "sha512-48sksG00073Nltma/BxpH6xHVZmoBjank40EU4h+XqrMGm0qM3jGngPO9R/iWAHdSduUWAoMJVJYA68AtvKgeQ==", + "version": "11.8.0", + "resolved": "https://registry.npmjs.org/@splitsoftware/splitio/-/splitio-11.8.0.tgz", + "integrity": "sha512-M9ENeH+IEmxwELeCdXgnTbLg+ZP3SRUMM6lImSbv7mD32u1v6ihnUhnhsTJzlQWMDC4H94EAW345q1cO7ovlTQ==", "requires": { - "@splitsoftware/splitio-commons": "2.6.0", + "@splitsoftware/splitio-commons": "2.8.0", "bloom-filters": "^3.0.4", "ioredis": "^4.28.0", "js-yaml": "^3.13.1", @@ -12109,9 +12109,9 @@ } }, "@splitsoftware/splitio-commons": { - "version": "2.6.0", - "resolved": "https://registry.npmjs.org/@splitsoftware/splitio-commons/-/splitio-commons-2.6.0.tgz", - "integrity": "sha512-0xODXLciIvHSuMlb8eukIB2epb3ZyGOsrwS0cMuTdxEvCqr7Nuc9pWDdJtRuN1UwL/jIjBnpDYAc8s6mpqLX2g==", + "version": "2.8.0", + "resolved": "https://registry.npmjs.org/@splitsoftware/splitio-commons/-/splitio-commons-2.8.0.tgz", + "integrity": "sha512-QgHUreMOEDwf4GZzVPu4AzkZJvuaeSoHsiJc4tT3CxSIYl2bKMz1SSDlI1tW/oVbIFeWjkrIp2lCYEyUBgcvyA==", "requires": { "@types/ioredis": "^4.28.0", "tslib": "^2.3.1" diff --git a/package.json b/package.json index e168d84..86da7e5 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "@splitsoftware/splitio-react", - "version": "2.5.0", + "version": "2.6.0", "description": "A React library to easily integrate and use Split JS SDK", "main": "cjs/index.js", "module": "esm/index.js", @@ -63,7 +63,7 @@ }, "homepage": "https://github.com/splitio/react-client#readme", "dependencies": { - "@splitsoftware/splitio": "11.6.0", + "@splitsoftware/splitio": "11.8.0", "memoize-one": "^5.1.1", "shallowequal": "^1.1.0", "tslib": "^2.3.1" diff --git a/src/SplitClient.tsx b/src/SplitClient.tsx index fd146ae..e94984d 100644 --- a/src/SplitClient.tsx +++ b/src/SplitClient.tsx @@ -9,8 +9,6 @@ import { useSplitClient } from './useSplitClient'; * * 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; diff --git a/src/SplitFactoryProvider.tsx b/src/SplitFactoryProvider.tsx index 05bbbca..e76830c 100644 --- a/src/SplitFactoryProvider.tsx +++ b/src/SplitFactoryProvider.tsx @@ -51,7 +51,7 @@ export function SplitFactoryProvider(props: ISplitFactoryProviderProps) { // Effect to initialize and destroy the factory when config is provided React.useEffect(() => { if (propFactory) { - if (config) console.log(WARN_SF_CONFIG_AND_FACTORY); + if (config) (propFactory.settings as any).log.warn(WARN_SF_CONFIG_AND_FACTORY); return; } diff --git a/src/SplitTreatments.tsx b/src/SplitTreatments.tsx index 7f52776..e895f72 100644 --- a/src/SplitTreatments.tsx +++ b/src/SplitTreatments.tsx @@ -9,7 +9,7 @@ import { useSplitTreatments } from './useSplitTreatments'; * 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. + * @deprecated `SplitTreatments` will be removed in a future major release. We recommend replacing it with the `useTreatment*` hooks. */ export function SplitTreatments(props: ISplitTreatmentsProps) { const { children } = props; diff --git a/src/__tests__/SplitClient.test.tsx b/src/__tests__/SplitClient.test.tsx index 2d1bf1b..a94aeb0 100644 --- a/src/__tests__/SplitClient.test.tsx +++ b/src/__tests__/SplitClient.test.tsx @@ -56,6 +56,7 @@ describe('SplitClient', () => { client: outerFactory.client(), isReady: true, isReadyFromCache: true, + isOperational: true, lastUpdate: getStatus(outerFactory.client()).lastUpdate }); @@ -141,7 +142,7 @@ describe('SplitClient', () => { 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]); + expect(statusProps).toStrictEqual([true, true, true, false]); break; default: fail('Child must not be rerendered'); @@ -182,7 +183,7 @@ describe('SplitClient', () => { 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 + expect(statusProps).toStrictEqual([true, true, true, false]); // not rerendering on SDK_TIMEOUT, but hasTimedout reflects the current state break; default: fail('Child must not be rerendered'); @@ -214,7 +215,7 @@ describe('SplitClient', () => { count++; // side effect in the render phase - if (!(client as any).__getStatus().isReady) { + if (!client!.getStatus().isReady) { (client as any).__emitter__.emit(Event.SDK_READY); } @@ -318,11 +319,11 @@ describe('SplitClient', () => { break; case 4: expect(client).toBe(outerFactory.client('user3')); - expect(statusProps).toStrictEqual([true, false, false, false]); + expect(statusProps).toStrictEqual([true, true, false, false]); break; case 5: expect(client).toBe(outerFactory.client('user3')); - expect(statusProps).toStrictEqual([true, false, false, false]); + expect(statusProps).toStrictEqual([true, true, false, false]); break; default: fail('Child must not be rerendered'); @@ -501,7 +502,7 @@ describe('SplitFactoryProvider + SplitClient', () => { 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]); + expect(statusProps).toStrictEqual([true, true, true, false]); break; default: fail('Child must not be rerendered'); @@ -542,7 +543,7 @@ describe('SplitFactoryProvider + SplitClient', () => { 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]); + expect(statusProps).toStrictEqual([true, true, true, false]); break; default: fail('Child must not be rerendered'); @@ -578,7 +579,7 @@ describe('SplitFactoryProvider + SplitClient', () => { 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 + expect(statusProps).toStrictEqual([true, true, true, false]); // not rerendering on SDK_TIMEOUT, but hasTimedout reflects the current state break; default: fail('Child must not be rerendered'); @@ -615,7 +616,7 @@ describe('SplitFactoryProvider + SplitClient', () => { 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 + expect(statusProps).toStrictEqual([true, true, true, false]); // not rerendering on SDK_TIMEOUT, but hasTimedout reflects the current state break; default: fail('Child must not be rerendered'); diff --git a/src/__tests__/SplitFactoryProvider.test.tsx b/src/__tests__/SplitFactoryProvider.test.tsx index 33f0977..86fd386 100644 --- a/src/__tests__/SplitFactoryProvider.test.tsx +++ b/src/__tests__/SplitFactoryProvider.test.tsx @@ -70,6 +70,7 @@ describe('SplitFactoryProvider', () => { client: outerFactory.client(), isReady: true, isReadyFromCache: true, + isOperational: true, lastUpdate: getStatus(outerFactory.client()).lastUpdate }); return null; @@ -113,7 +114,7 @@ describe('SplitFactoryProvider', () => {
); - expect(logSpy).toBeCalledWith(WARN_SF_CONFIG_AND_FACTORY); + expect(logSpy).toBeCalledWith('[WARN] splitio => ' + WARN_SF_CONFIG_AND_FACTORY); logSpy.mockRestore(); }); diff --git a/src/__tests__/SplitTreatments.test.tsx b/src/__tests__/SplitTreatments.test.tsx index cf21ea4..243ddb6 100644 --- a/src/__tests__/SplitTreatments.test.tsx +++ b/src/__tests__/SplitTreatments.test.tsx @@ -19,15 +19,11 @@ 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( @@ -73,7 +69,7 @@ describe('SplitTreatments', () => { 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, getStatus(outerFactory.client()).lastUpdate]); + expect([isReady2, isReadyFromCache, hasTimedout, isTimedout, isDestroyed, lastUpdate]).toStrictEqual([true, true, false, false, false, getStatus(outerFactory.client()).lastUpdate]); return null; }} @@ -105,10 +101,9 @@ describe('SplitTreatments', () => { }); /** - * Input validation. Passing invalid feature flag names or attributes while the Sdk - * is not ready doesn't emit errors, and logs meaningful messages instead. + * Input validation: sanitize invalid feature flag names and return control while the SDK is not ready. */ - it('Input validation: invalid "names" and "attributes" props in SplitTreatments.', (done) => { + it('Input validation: invalid names are sanitized.', () => { render( @@ -130,9 +125,9 @@ describe('SplitTreatments', () => { }} {/* @ts-expect-error Test error handling */} - + {({ treatments }: ISplitTreatmentsChildProps) => { - expect(treatments).toEqual({}); + expect(treatments).toEqual({ flag_1: CONTROL_WITH_CONFIG }); return null; }} @@ -142,14 +137,9 @@ describe('SplitTreatments', () => { ); - 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.', () => { + test('ignores flagSets if both names and flagSets params are provided.', () => { render( {/* @ts-expect-error flagSets and names are mutually exclusive */} @@ -161,8 +151,6 @@ describe('SplitTreatments', () => { ); - - 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 () => { @@ -190,7 +178,7 @@ describe('SplitTreatments', () => { act(() => client.__emitter__.emit(Event.SDK_READY_FROM_CACHE)); expect(client.getTreatmentsWithConfig).toBeCalledWith(featureFlagNames, attributes, undefined); - expect(client.getTreatmentsWithConfig).toHaveReturnedWith(treatments); + expect(client.getTreatmentsWithConfig).toHaveReturnedWith(treatments!); }); }); diff --git a/src/__tests__/index.test.ts b/src/__tests__/index.test.ts index a455af6..d63b749 100644 --- a/src/__tests__/index.test.ts +++ b/src/__tests__/index.test.ts @@ -12,6 +12,10 @@ import { useSplitClient as exportedUseSplitClient, useSplitTreatments as exportedUseSplitTreatments, useSplitManager as exportedUseSplitManager, + useTreatment as exportedUseTreatment, + useTreatmentWithConfig as exportedUseTreatmentWithConfig, + useTreatments as exportedUseTreatments, + useTreatmentsWithConfig as exportedUseTreatmentsWithConfig, // Checks that types are exported. Otherwise, the test would fail with a TS error. GetTreatmentsOptions, ISplitClientChildProps, @@ -39,6 +43,10 @@ import { useTrack } from '../useTrack'; import { useSplitClient } from '../useSplitClient'; import { useSplitTreatments } from '../useSplitTreatments'; import { useSplitManager } from '../useSplitManager'; +import { useTreatment } from '../useTreatment'; +import { useTreatmentWithConfig } from '../useTreatmentWithConfig'; +import { useTreatments } from '../useTreatments'; +import { useTreatmentsWithConfig } from '../useTreatmentsWithConfig'; describe('index', () => { @@ -59,6 +67,10 @@ describe('index', () => { expect(exportedUseSplitClient).toBe(useSplitClient); expect(exportedUseSplitTreatments).toBe(useSplitTreatments); expect(exportedUseSplitManager).toBe(useSplitManager); + expect(exportedUseTreatment).toBe(useTreatment); + expect(exportedUseTreatmentWithConfig).toBe(useTreatmentWithConfig); + expect(exportedUseTreatments).toBe(useTreatments); + expect(exportedUseTreatmentsWithConfig).toBe(useTreatmentsWithConfig); }); it('should export SplitContext', () => { diff --git a/src/__tests__/testUtils/mockSplitFactory.ts b/src/__tests__/testUtils/mockSplitFactory.ts index 09dca47..a6f5ddb 100644 --- a/src/__tests__/testUtils/mockSplitFactory.ts +++ b/src/__tests__/testUtils/mockSplitFactory.ts @@ -1,6 +1,7 @@ import { EventEmitter } from 'events'; import jsSdkPackageJson from '@splitsoftware/splitio/package.json'; import reactSdkPackageJson from '../../../package.json'; +import { CONTROL, CONTROL_WITH_CONFIG } from '../../constants'; export const jsSdkVersion = `javascript-${jsSdkPackageJson.version}`; export const reactSdkVersion = `react-${reactSdkPackageJson.version}`; @@ -12,6 +13,13 @@ export const Event = { SDK_UPDATE: 'state::update', }; +const DEFAULT_LOGGER: SplitIO.Logger = { + error(msg) { console.log('[ERROR] splitio => ' + msg); }, + warn(msg) { console.log('[WARN] splitio => ' + msg); }, + info(msg) { console.log('[INFO] splitio => ' + msg); }, + debug(msg) { console.log('[DEBUG] splitio => ' + msg); }, +}; + function parseKey(key: SplitIO.SplitKey): SplitIO.SplitKey { if (key && typeof key === 'object' && key.constructor === Object) { return { @@ -47,7 +55,7 @@ export function mockSdk() { } const __emitter__ = new EventEmitter(); - __emitter__.on(Event.SDK_READY, () => { isReady = true; syncLastUpdate(); }); + __emitter__.on(Event.SDK_READY, () => { isReady = true; isReadyFromCache = true; syncLastUpdate(); }); __emitter__.on(Event.SDK_READY_FROM_CACHE, () => { isReadyFromCache = true; syncLastUpdate(); }); __emitter__.on(Event.SDK_READY_TIMED_OUT, () => { hasTimedout = true; syncLastUpdate(); }); __emitter__.on(Event.SDK_UPDATE, () => { syncLastUpdate(); }); @@ -58,6 +66,24 @@ export function mockSdk() { const track: jest.Mock = jest.fn(() => { return true; }); + const getTreatment: jest.Mock = jest.fn((featureFlagName: string) => { + return typeof featureFlagName === 'string' ? 'on' : CONTROL; + }); + const getTreatments: jest.Mock = jest.fn((featureFlagNames: string[]) => { + return featureFlagNames.reduce((result: SplitIO.Treatments, featureName: string) => { + result[featureName] = 'on'; + return result; + }, {}); + }); + const getTreatmentsByFlagSets: jest.Mock = jest.fn((flagSets: string[]) => { + return flagSets.reduce((result: SplitIO.Treatments, flagSet: string) => { + result[flagSet + '_feature_flag'] = 'on'; + return result; + }, {}); + }); + const getTreatmentWithConfig: jest.Mock = jest.fn((featureFlagName: string) => { + return typeof featureFlagName === 'string' ? { treatment: 'on', config: null } : CONTROL_WITH_CONFIG; + }); const getTreatmentsWithConfig: jest.Mock = jest.fn((featureFlagNames: string[]) => { return featureFlagNames.reduce((result: SplitIO.TreatmentsWithConfig, featureName: string) => { result[featureName] = { treatment: 'on', config: null }; @@ -89,13 +115,13 @@ export function mockSdk() { else { __emitter__.on(Event.SDK_READY_TIMED_OUT, rej); } }); }); - const __getStatus = () => ({ + const getStatus = () => ({ isReady, isReadyFromCache, isTimedout: hasTimedout && !isReady, hasTimedout, isDestroyed, - isOperational: (isReady || isReadyFromCache) && !isDestroyed, + isOperational: isReadyFromCache && !isDestroyed, lastUpdate, }); const destroy: jest.Mock = jest.fn(() => { @@ -106,6 +132,10 @@ export function mockSdk() { }); return Object.assign(Object.create(__emitter__), { + getTreatment, + getTreatments, + getTreatmentsByFlagSets, + getTreatmentWithConfig, getTreatmentsWithConfig, getTreatmentsWithConfigByFlagSets, track, @@ -115,10 +145,9 @@ export function mockSdk() { setAttributes, clearAttributes, getAttributes, + getStatus, // EventEmitter exposed to trigger events manually __emitter__, - // Clients expose a `__getStatus` method, that is not considered part of the public API, to get client readiness status (isReady, isReadyFromCache, isOperational, hasTimedout, isDestroyed) - __getStatus, // Restore the mock client to its initial NO-READY status. // Useful when you want to reuse the same mock between tests after emitting events or destroying the instance. __restore() { @@ -154,6 +183,7 @@ export function mockSdk() { __clients__, settings: Object.assign({ version: jsSdkVersion, + log: DEFAULT_LOGGER }, config), }; diff --git a/src/__tests__/testUtils/sdkConfigs.ts b/src/__tests__/testUtils/sdkConfigs.ts index 0ec97f5..b3397e6 100644 --- a/src/__tests__/testUtils/sdkConfigs.ts +++ b/src/__tests__/testUtils/sdkConfigs.ts @@ -4,3 +4,11 @@ export const sdkBrowser: SplitIO.IBrowserSettings = { key: 'customer-key', }, }; + +export const sdkBrowserWithConfig: SplitIO.IBrowserSettings = { + ...sdkBrowser, + fallbackTreatments: { + global: 'control_global', + byFlag: { ff1: { treatment: 'control_ff1', config: 'control_ff1_config' } } + } +}; diff --git a/src/__tests__/testUtils/utils.tsx b/src/__tests__/testUtils/utils.tsx index 7571873..9c05ec5 100644 --- a/src/__tests__/testUtils/utils.tsx +++ b/src/__tests__/testUtils/utils.tsx @@ -123,6 +123,7 @@ export const INITIAL_STATUS: ISplitStatus & IUpdateProps = { hasTimedout: false, lastUpdate: 0, isDestroyed: false, + isOperational: false, updateOnSdkReady: true, updateOnSdkReadyFromCache: true, updateOnSdkTimedout: true, diff --git a/src/__tests__/useSplitClient.test.tsx b/src/__tests__/useSplitClient.test.tsx index ea56ad9..5e4bb03 100644 --- a/src/__tests__/useSplitClient.test.tsx +++ b/src/__tests__/useSplitClient.test.tsx @@ -207,7 +207,7 @@ describe('useSplitClient', () => { // side effect in the render phase const client = outerFactory.client('some_user') as any; - if (!client.__getStatus().isReady) client.__emitter__.emit(Event.SDK_READY); + if (!client.getStatus().isReady) client.__emitter__.emit(Event.SDK_READY); return null; })} @@ -256,7 +256,7 @@ describe('useSplitClient', () => { act(() => mainClient.__emitter__.emit(Event.SDK_READY)); // trigger re-render expect(rendersCount).toBe(5); - expect(currentStatus).toMatchObject({ isReady: true, isReadyFromCache: false, hasTimedout: true }); + expect(currentStatus).toMatchObject({ isReady: true, isReadyFromCache: true, hasTimedout: true }); act(() => mainClient.__emitter__.emit(Event.SDK_UPDATE)); // do not trigger re-render because updateOnSdkUpdate is false expect(rendersCount).toBe(5); diff --git a/src/__tests__/useSplitManager.test.tsx b/src/__tests__/useSplitManager.test.tsx index 9707042..f8aff53 100644 --- a/src/__tests__/useSplitManager.test.tsx +++ b/src/__tests__/useSplitManager.test.tsx @@ -48,8 +48,9 @@ describe('useSplitManager', () => { hasTimedout: false, isDestroyed: false, isReady: true, - isReadyFromCache: false, + isReadyFromCache: true, isTimedout: false, + isOperational: true, lastUpdate: getStatus(outerFactory.client()).lastUpdate, }); }); @@ -98,8 +99,9 @@ describe('useSplitManager', () => { hasTimedout: false, isDestroyed: false, isReady: true, - isReadyFromCache: false, + isReadyFromCache: true, isTimedout: false, + isOperational: true, lastUpdate: getStatus(outerFactory.client()).lastUpdate, }); }); diff --git a/src/__tests__/useTreatment.test.tsx b/src/__tests__/useTreatment.test.tsx new file mode 100644 index 0000000..69bde09 --- /dev/null +++ b/src/__tests__/useTreatment.test.tsx @@ -0,0 +1,186 @@ +import * as React from 'react'; +import { act, 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, sdkBrowserWithConfig } from './testUtils/sdkConfigs'; +import { CONTROL, EXCEPTION_NO_SFP } from '../constants'; + +/** Test target */ +import { SplitFactoryProvider } from '../SplitFactoryProvider'; +import { useTreatment } from '../useTreatment'; +import { SplitContext } from '../SplitContext'; +import { IUseTreatmentResult } from '../types'; + +describe('useTreatment', () => { + + const featureFlagName = 'split1'; + const attributes = { att1: 'att1' }; + const properties = { prop1: 'prop1' }; + + test('returns the treatment evaluated by the main client of the factory at Split context, or control if the client is not operational.', () => { + const outerFactory = SplitFactory(sdkBrowser); + const client: any = outerFactory.client(); + let treatment: SplitIO.Treatment; + + render( + + {React.createElement(() => { + treatment = useTreatment({ name: featureFlagName, attributes, properties }).treatment; + return null; + })} + + ); + + // returns control treatment if not operational (SDK not ready or destroyed), without calling `getTreatment` method + expect(client.getTreatment).not.toBeCalled(); + expect(treatment!).toEqual(CONTROL); + + // once operational (SDK_READY), it evaluates feature flags + act(() => client.__emitter__.emit(Event.SDK_READY)); + + expect(client.getTreatment).toBeCalledWith(featureFlagName, attributes, { properties }); + expect(client.getTreatment).toHaveReturnedWith(treatment!); + }); + + test('returns the treatments from a new client given a splitKey, and re-evaluates on SDK events.', () => { + const outerFactory = SplitFactory(sdkBrowser); + const client: any = outerFactory.client('user2'); + let renderTimes = 0; + + render( + + {React.createElement(() => { + const treatment = useTreatment({ name: featureFlagName, attributes, properties, splitKey: 'user2', updateOnSdkUpdate: false }).treatment; + + renderTimes++; + switch (renderTimes) { + case 1: + // returns control if not operational (SDK not ready), without calling `getTreatment` method + expect(client.getTreatment).not.toBeCalled(); + expect(treatment).toEqual(CONTROL); + break; + case 2: + case 3: + // once operational (SDK_READY or SDK_READY_FROM_CACHE), it evaluates feature flags + expect(client.getTreatment).toHaveBeenLastCalledWith(featureFlagName, attributes, { properties }); + expect(client.getTreatment).toHaveLastReturnedWith(treatment); + break; + default: + throw new Error('Unexpected render'); + } + + return null; + })} + + ); + + act(() => client.__emitter__.emit(Event.SDK_READY_FROM_CACHE)); + act(() => client.__emitter__.emit(Event.SDK_READY)); + act(() => client.__emitter__.emit(Event.SDK_UPDATE)); + expect(client.getTreatment).toBeCalledTimes(2); + }); + + test('throws error if invoked outside of SplitFactoryProvider.', () => { + expect(() => { + render( + React.createElement(() => { + useTreatment({ name: featureFlagName, attributes }).treatment; + return null; + }) + ); + }).toThrow(EXCEPTION_NO_SFP); + }); + + test('must update on SDK events', async () => { + const outerFactory = SplitFactory(sdkBrowser); + const mainClient = outerFactory.client() as any; + const user2Client = outerFactory.client('user_2') as any; + + let countSplitContext = 0, countUseTreatment = 0, countUseTreatmentUser2 = 0, countUseTreatmentUser2WithoutUpdate = 0; + const lastUpdateSetUser2 = new Set(); + const lastUpdateSetUser2WithUpdate = new Set(); + + function validateTreatment({ treatment, isReady, isReadyFromCache }: IUseTreatmentResult) { + if (isReady || isReadyFromCache) { + expect(treatment).toEqual('on') + } else { + expect(treatment).toEqual('control') + } + } + + render( + + <> + + {() => countSplitContext++} + + {React.createElement(() => { + const context = useTreatment({ name: 'split_test', attributes: { att1: 'att1' } }); + expect(context.client).toBe(mainClient); // Assert that the main client was retrieved. + validateTreatment(context); + countUseTreatment++; + return null; + })} + {React.createElement(() => { + const context = useTreatment({ name: 'split_test', splitKey: 'user_2' }); + expect(context.client).toBe(user2Client); + validateTreatment(context); + lastUpdateSetUser2.add(context.lastUpdate); + countUseTreatmentUser2++; + return null; + })} + {React.createElement(() => { + const context = useTreatment({ name: 'split_test', splitKey: 'user_2', updateOnSdkUpdate: false }); + expect(context.client).toBe(user2Client); + validateTreatment(context); + lastUpdateSetUser2WithUpdate.add(context.lastUpdate); + countUseTreatmentUser2WithoutUpdate++; + return null; + })} + + + ); + + act(() => mainClient.__emitter__.emit(Event.SDK_READY_FROM_CACHE)); + act(() => mainClient.__emitter__.emit(Event.SDK_READY)); + act(() => mainClient.__emitter__.emit(Event.SDK_UPDATE)); + act(() => user2Client.__emitter__.emit(Event.SDK_READY_FROM_CACHE)); + act(() => user2Client.__emitter__.emit(Event.SDK_READY)); + act(() => user2Client.__emitter__.emit(Event.SDK_UPDATE)); + + // SplitFactoryProvider renders once + expect(countSplitContext).toEqual(1); + + // If useTreatment evaluates with the main client and have default update options, it re-renders for each main client event. + expect(countUseTreatment).toEqual(4); + expect(mainClient.getTreatment).toHaveBeenCalledTimes(3); // when ready from cache, ready and update + expect(mainClient.getTreatment).toHaveBeenLastCalledWith('split_test', { att1: 'att1' }, undefined); + + // If useTreatment evaluates with a different client and have default update options, it re-renders for each event of the new client. + expect(countUseTreatmentUser2).toEqual(4); + expect(lastUpdateSetUser2.size).toEqual(4); + // If it is used with `updateOnSdkUpdate: false`, it doesn't render when the client emits an SDK_UPDATE event. + expect(countUseTreatmentUser2WithoutUpdate).toEqual(3); + expect(lastUpdateSetUser2WithUpdate.size).toEqual(3); + expect(user2Client.getTreatment).toHaveBeenCalledTimes(5); // when ready from cache x2, ready x2 and update x1 + expect(user2Client.getTreatment).toHaveBeenLastCalledWith('split_test', undefined, undefined); + }); + + test('returns fallback treatment if the client is not operational', () => { + render( + + {React.createElement(() => { + expect(useTreatment({ name: featureFlagName, attributes, properties }).treatment).toEqual('control_global'); + expect(useTreatment({ name: 'ff1', attributes, properties }).treatment).toEqual('control_ff1'); + return null; + })} + + ); + }); + +}); diff --git a/src/__tests__/useTreatmentWithConfig.test.tsx b/src/__tests__/useTreatmentWithConfig.test.tsx new file mode 100644 index 0000000..f74ac42 --- /dev/null +++ b/src/__tests__/useTreatmentWithConfig.test.tsx @@ -0,0 +1,186 @@ +import * as React from 'react'; +import { act, 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, sdkBrowserWithConfig } from './testUtils/sdkConfigs'; +import { CONTROL_WITH_CONFIG, EXCEPTION_NO_SFP } from '../constants'; + +/** Test target */ +import { SplitFactoryProvider } from '../SplitFactoryProvider'; +import { useTreatmentWithConfig } from '../useTreatmentWithConfig'; +import { SplitContext } from '../SplitContext'; +import { IUseTreatmentWithConfigResult } from '../types'; + +describe('useTreatmentWithConfig', () => { + + const featureFlagName = 'split1'; + const attributes = { att1: 'att1' }; + const properties = { prop1: 'prop1' }; + + test('returns the treatment evaluated by the main client of the factory at Split context, or control if the client is not operational.', () => { + const outerFactory = SplitFactory(sdkBrowser); + const client: any = outerFactory.client(); + let treatment: SplitIO.TreatmentWithConfig; + + render( + + {React.createElement(() => { + treatment = useTreatmentWithConfig({ name: featureFlagName, attributes, properties }).treatment; + return null; + })} + + ); + + // returns control treatment if not operational (SDK not ready or destroyed), without calling `getTreatmentWithConfig` method + expect(client.getTreatmentWithConfig).not.toBeCalled(); + expect(treatment!).toEqual(CONTROL_WITH_CONFIG); + + // once operational (SDK_READY), it evaluates feature flags + act(() => client.__emitter__.emit(Event.SDK_READY)); + + expect(client.getTreatmentWithConfig).toBeCalledWith(featureFlagName, attributes, { properties }); + expect(client.getTreatmentWithConfig).toHaveReturnedWith(treatment!); + }); + + test('returns the treatments from a new client given a splitKey, and re-evaluates on SDK events.', () => { + const outerFactory = SplitFactory(sdkBrowser); + const client: any = outerFactory.client('user2'); + let renderTimes = 0; + + render( + + {React.createElement(() => { + const treatment = useTreatmentWithConfig({ name: featureFlagName, attributes, properties, splitKey: 'user2', updateOnSdkUpdate: false }).treatment; + + renderTimes++; + switch (renderTimes) { + case 1: + // returns control if not operational (SDK not ready), without calling `getTreatmentWithConfig` method + expect(client.getTreatmentWithConfig).not.toBeCalled(); + expect(treatment).toEqual(CONTROL_WITH_CONFIG); + break; + case 2: + case 3: + // once operational (SDK_READY or SDK_READY_FROM_CACHE), it evaluates feature flags + expect(client.getTreatmentWithConfig).toHaveBeenLastCalledWith(featureFlagName, attributes, { properties }); + expect(client.getTreatmentWithConfig).toHaveLastReturnedWith(treatment); + break; + default: + throw new Error('Unexpected render'); + } + + return null; + })} + + ); + + act(() => client.__emitter__.emit(Event.SDK_READY_FROM_CACHE)); + act(() => client.__emitter__.emit(Event.SDK_READY)); + act(() => client.__emitter__.emit(Event.SDK_UPDATE)); + expect(client.getTreatmentWithConfig).toBeCalledTimes(2); + }); + + test('throws error if invoked outside of SplitFactoryProvider.', () => { + expect(() => { + render( + React.createElement(() => { + useTreatmentWithConfig({ name: featureFlagName, attributes }).treatment; + return null; + }) + ); + }).toThrow(EXCEPTION_NO_SFP); + }); + + test('must update on SDK events', async () => { + const outerFactory = SplitFactory(sdkBrowser); + const mainClient = outerFactory.client() as any; + const user2Client = outerFactory.client('user_2') as any; + + let countSplitContext = 0, countUseTreatmentWithConfig = 0, countUseTreatmentWithConfigUser2 = 0, countUseTreatmentWithConfigUser2WithoutUpdate = 0; + const lastUpdateSetUser2 = new Set(); + const lastUpdateSetUser2WithUpdate = new Set(); + + function validateTreatment({ treatment, isReady, isReadyFromCache }: IUseTreatmentWithConfigResult) { + if (isReady || isReadyFromCache) { + expect(treatment).toEqual({ treatment: 'on', config: null }) + } else { + expect(treatment).toEqual({ treatment: 'control', config: null }) + } + } + + render( + + <> + + {() => countSplitContext++} + + {React.createElement(() => { + const context = useTreatmentWithConfig({ name: 'split_test', attributes: { att1: 'att1' } }); + expect(context.client).toBe(mainClient); // Assert that the main client was retrieved. + validateTreatment(context); + countUseTreatmentWithConfig++; + return null; + })} + {React.createElement(() => { + const context = useTreatmentWithConfig({ name: 'split_test', splitKey: 'user_2' }); + expect(context.client).toBe(user2Client); + validateTreatment(context); + lastUpdateSetUser2.add(context.lastUpdate); + countUseTreatmentWithConfigUser2++; + return null; + })} + {React.createElement(() => { + const context = useTreatmentWithConfig({ name: 'split_test', splitKey: 'user_2', updateOnSdkUpdate: false }); + expect(context.client).toBe(user2Client); + validateTreatment(context); + lastUpdateSetUser2WithUpdate.add(context.lastUpdate); + countUseTreatmentWithConfigUser2WithoutUpdate++; + return null; + })} + + + ); + + act(() => mainClient.__emitter__.emit(Event.SDK_READY_FROM_CACHE)); + act(() => mainClient.__emitter__.emit(Event.SDK_READY)); + act(() => mainClient.__emitter__.emit(Event.SDK_UPDATE)); + act(() => user2Client.__emitter__.emit(Event.SDK_READY_FROM_CACHE)); + act(() => user2Client.__emitter__.emit(Event.SDK_READY)); + act(() => user2Client.__emitter__.emit(Event.SDK_UPDATE)); + + // SplitFactoryProvider renders once + expect(countSplitContext).toEqual(1); + + // If useTreatmentWithConfig evaluates with the main client and have default update options, it re-renders for each main client event. + expect(countUseTreatmentWithConfig).toEqual(4); + expect(mainClient.getTreatmentWithConfig).toHaveBeenCalledTimes(3); // when ready from cache, ready and update + expect(mainClient.getTreatmentWithConfig).toHaveBeenLastCalledWith('split_test', { att1: 'att1' }, undefined); + + // If useTreatmentWithConfig evaluates with a different client and have default update options, it re-renders for each event of the new client. + expect(countUseTreatmentWithConfigUser2).toEqual(4); + expect(lastUpdateSetUser2.size).toEqual(4); + // If it is used with `updateOnSdkUpdate: false`, it doesn't render when the client emits an SDK_UPDATE event. + expect(countUseTreatmentWithConfigUser2WithoutUpdate).toEqual(3); + expect(lastUpdateSetUser2WithUpdate.size).toEqual(3); + expect(user2Client.getTreatmentWithConfig).toHaveBeenCalledTimes(5); // when ready from cache x2, ready x2 and update x1 + expect(user2Client.getTreatmentWithConfig).toHaveBeenLastCalledWith('split_test', undefined, undefined); + }); + + test('returns fallback treatment if the client is not operational', () => { + render( + + {React.createElement(() => { + expect(useTreatmentWithConfig({ name: featureFlagName, attributes, properties }).treatment).toEqual({ treatment: 'control_global', config: null }); + expect(useTreatmentWithConfig({ name: 'ff1', attributes, properties }).treatment).toEqual({ treatment: 'control_ff1', config: 'control_ff1_config' }); + return null; + })} + + ); + }); + +}); diff --git a/src/__tests__/useTreatments.test.tsx b/src/__tests__/useTreatments.test.tsx new file mode 100644 index 0000000..98712b4 --- /dev/null +++ b/src/__tests__/useTreatments.test.tsx @@ -0,0 +1,241 @@ +import * as React from 'react'; +import { act, 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, sdkBrowserWithConfig } from './testUtils/sdkConfigs'; +import { CONTROL, EXCEPTION_NO_SFP } from '../constants'; + +/** Test target */ +import { SplitFactoryProvider } from '../SplitFactoryProvider'; +import { useTreatments } from '../useTreatments'; +import { SplitContext } from '../SplitContext'; +import { IUseTreatmentsResult } from '../types'; + +describe('useTreatments', () => { + + const featureFlagNames = ['split1']; + const flagSets = ['set1']; + const attributes = { att1: 'att1' }; + const properties = { prop1: 'prop1' }; + + test('returns the treatments evaluated by the main client of the factory at Split context, or control if the client is not operational.', () => { + const outerFactory = SplitFactory(sdkBrowser); + const client: any = outerFactory.client(); + let treatments: SplitIO.Treatments; + let treatmentsByFlagSets: SplitIO.Treatments; + + render( + + {React.createElement(() => { + treatments = useTreatments({ names: featureFlagNames, attributes, properties }).treatments; + treatmentsByFlagSets = useTreatments({ flagSets, attributes, properties }).treatments; + + // @ts-expect-error Options object must provide either names or flagSets + expect(useTreatments({}).treatments).toEqual({}); + return null; + })} + + ); + + // returns control treatment if not operational (SDK not ready or destroyed), without calling `getTreatments` method + expect(client.getTreatments).not.toBeCalled(); + expect(treatments!).toEqual({ split1: CONTROL }); + + // returns empty treatments object if not operational, without calling `getTreatmentsByFlagSets` method + expect(client.getTreatmentsByFlagSets).not.toBeCalled(); + expect(treatmentsByFlagSets!).toEqual({}); + + // once operational (SDK_READY), it evaluates feature flags + act(() => client.__emitter__.emit(Event.SDK_READY)); + + expect(client.getTreatments).toBeCalledWith(featureFlagNames, attributes, { properties }); + expect(client.getTreatments).toHaveReturnedWith(treatments!); + + expect(client.getTreatmentsByFlagSets).toBeCalledWith(flagSets, attributes, { properties }); + expect(client.getTreatmentsByFlagSets).toHaveReturnedWith(treatmentsByFlagSets!); + }); + + test('returns the treatments from a new client given a splitKey, and re-evaluates on SDK events.', () => { + const outerFactory = SplitFactory(sdkBrowser); + const client: any = outerFactory.client('user2'); + let renderTimes = 0; + + render( + + {React.createElement(() => { + const treatments = useTreatments({ names: featureFlagNames, attributes, properties, splitKey: 'user2', updateOnSdkUpdate: false }).treatments; + + renderTimes++; + switch (renderTimes) { + case 1: + // returns control if not operational (SDK not ready), without calling `getTreatments` method + expect(client.getTreatments).not.toBeCalled(); + expect(treatments).toEqual({ split1: CONTROL }); + break; + case 2: + case 3: + // once operational (SDK_READY or SDK_READY_FROM_CACHE), it evaluates feature flags + expect(client.getTreatments).toHaveBeenLastCalledWith(featureFlagNames, attributes, { properties }); + expect(client.getTreatments).toHaveLastReturnedWith(treatments); + break; + default: + throw new Error('Unexpected render'); + } + + return null; + })} + + ); + + act(() => client.__emitter__.emit(Event.SDK_READY_FROM_CACHE)); + act(() => client.__emitter__.emit(Event.SDK_READY)); + act(() => client.__emitter__.emit(Event.SDK_UPDATE)); + expect(client.getTreatments).toBeCalledTimes(2); + }); + + test('throws error if invoked outside of SplitFactoryProvider.', () => { + expect(() => { + render( + React.createElement(() => { + useTreatments({ names: featureFlagNames, attributes }).treatments; + useTreatments({ flagSets: featureFlagNames }).treatments; + return null; + }) + ); + }).toThrow(EXCEPTION_NO_SFP); + }); + + /** + * Input validation: sanitize invalid feature flag names and return control while the SDK is not ready. + */ + test('Input validation: invalid names are sanitized.', () => { + render( + + { + React.createElement(() => { + // @ts-expect-error Test error handling + let treatments = useTreatments('split1').treatments; + expect(treatments).toEqual({}); + // @ts-expect-error Test error handling + treatments = useTreatments({ names: [true, ' flag_1 ', ' '] }).treatments; + expect(treatments).toEqual({ flag_1: CONTROL }); + + return null; + }) + } + + ); + }); + + test('must update on SDK events', async () => { + const outerFactory = SplitFactory(sdkBrowser); + const mainClient = outerFactory.client() as any; + const user2Client = outerFactory.client('user_2') as any; + + let countSplitContext = 0, countUseTreatments = 0, countUseTreatmentsUser2 = 0, countUseTreatmentsUser2WithoutUpdate = 0; + const lastUpdateSetUser2 = new Set(); + const lastUpdateSetUser2WithUpdate = new Set(); + + function validateTreatments({ treatments, isReady, isReadyFromCache }: IUseTreatmentsResult) { + if (isReady || isReadyFromCache) { + expect(treatments).toEqual({ + split_test: 'on' + }) + } else { + expect(treatments).toEqual({ + split_test: 'control' + }) + } + } + + render( + + <> + + {() => countSplitContext++} + + {React.createElement(() => { + const context = useTreatments({ names: ['split_test'], attributes: { att1: 'att1' } }); + expect(context.client).toBe(mainClient); // Assert that the main client was retrieved. + validateTreatments(context); + countUseTreatments++; + return null; + })} + {React.createElement(() => { + const context = useTreatments({ names: ['split_test'], splitKey: 'user_2' }); + expect(context.client).toBe(user2Client); + validateTreatments(context); + lastUpdateSetUser2.add(context.lastUpdate); + countUseTreatmentsUser2++; + return null; + })} + {React.createElement(() => { + const context = useTreatments({ names: ['split_test'], splitKey: 'user_2', updateOnSdkUpdate: false }); + expect(context.client).toBe(user2Client); + validateTreatments(context); + lastUpdateSetUser2WithUpdate.add(context.lastUpdate); + countUseTreatmentsUser2WithoutUpdate++; + return null; + })} + + + ); + + act(() => mainClient.__emitter__.emit(Event.SDK_READY_FROM_CACHE)); + act(() => mainClient.__emitter__.emit(Event.SDK_READY)); + act(() => mainClient.__emitter__.emit(Event.SDK_UPDATE)); + act(() => user2Client.__emitter__.emit(Event.SDK_READY_FROM_CACHE)); + act(() => user2Client.__emitter__.emit(Event.SDK_READY)); + act(() => user2Client.__emitter__.emit(Event.SDK_UPDATE)); + + // SplitFactoryProvider renders once + expect(countSplitContext).toEqual(1); + + // If useTreatments evaluates with the main client and have default update options, it re-renders for each main client event. + expect(countUseTreatments).toEqual(4); + expect(mainClient.getTreatments).toHaveBeenCalledTimes(3); // when ready from cache, ready and update + expect(mainClient.getTreatments).toHaveBeenLastCalledWith(['split_test'], { att1: 'att1' }, undefined); + + // If useTreatments evaluates with a different client and have default update options, it re-renders for each event of the new client. + expect(countUseTreatmentsUser2).toEqual(4); + expect(lastUpdateSetUser2.size).toEqual(4); + // If it is used with `updateOnSdkUpdate: false`, it doesn't render when the client emits an SDK_UPDATE event. + expect(countUseTreatmentsUser2WithoutUpdate).toEqual(3); + expect(lastUpdateSetUser2WithUpdate.size).toEqual(3); + expect(user2Client.getTreatments).toHaveBeenCalledTimes(5); // when ready from cache x2, ready x2 and update x1 + expect(user2Client.getTreatments).toHaveBeenLastCalledWith(['split_test'], undefined, undefined); + }); + + test('ignores flagSets if both names and flagSets params are provided.', () => { + render( + + { + React.createElement(() => { + // @ts-expect-error names and flagSets are mutually exclusive + const treatments = useTreatments({ names: featureFlagNames, flagSets, attributes }).treatments; + expect(treatments).toEqual({ split1: CONTROL }); + return null; + }) + } + + ); + }); + + test('returns fallback treatments if the client is not operational', () => { + render( + + {React.createElement(() => { + const { treatments } = useTreatments({ names: ['ff1', 'ff2'], attributes, properties }); + expect(treatments).toEqual({ ff1: 'control_ff1', ff2: 'control_global' }); + return null; + })} + + ); + }); + +}); diff --git a/src/__tests__/useSplitTreatments.test.tsx b/src/__tests__/useTreatmentsWithConfig.test.tsx similarity index 69% rename from src/__tests__/useSplitTreatments.test.tsx rename to src/__tests__/useTreatmentsWithConfig.test.tsx index c93454d..a8c5c0b 100644 --- a/src/__tests__/useSplitTreatments.test.tsx +++ b/src/__tests__/useTreatmentsWithConfig.test.tsx @@ -7,18 +7,16 @@ jest.mock('@splitsoftware/splitio/client', () => { return { SplitFactory: mockSdk() }; }); import { SplitFactory } from '@splitsoftware/splitio/client'; -import { sdkBrowser } from './testUtils/sdkConfigs'; +import { sdkBrowser, sdkBrowserWithConfig } from './testUtils/sdkConfigs'; import { CONTROL_WITH_CONFIG, EXCEPTION_NO_SFP } from '../constants'; /** Test target */ import { SplitFactoryProvider } from '../SplitFactoryProvider'; -import { useSplitTreatments } from '../useSplitTreatments'; +import { useTreatmentsWithConfig } from '../useTreatmentsWithConfig'; import { SplitContext } from '../SplitContext'; -import { ISplitTreatmentsChildProps } from '../types'; +import { IUseTreatmentsWithConfigResult } from '../types'; -const logSpy = jest.spyOn(console, 'log'); - -describe('useSplitTreatments', () => { +describe('useTreatmentsWithConfig', () => { const featureFlagNames = ['split1']; const flagSets = ['set1']; @@ -34,11 +32,11 @@ describe('useSplitTreatments', () => { render( {React.createElement(() => { - treatments = useSplitTreatments({ names: featureFlagNames, attributes, properties }).treatments; - treatmentsByFlagSets = useSplitTreatments({ flagSets, attributes, properties }).treatments; + treatments = useTreatmentsWithConfig({ names: featureFlagNames, attributes, properties }).treatments; + treatmentsByFlagSets = useTreatmentsWithConfig({ flagSets, attributes, properties }).treatments; // @ts-expect-error Options object must provide either names or flagSets - expect(useSplitTreatments({}).treatments).toEqual({}); + expect(useTreatmentsWithConfig({}).treatments).toEqual({}); return null; })} @@ -56,10 +54,10 @@ describe('useSplitTreatments', () => { act(() => client.__emitter__.emit(Event.SDK_READY)); expect(client.getTreatmentsWithConfig).toBeCalledWith(featureFlagNames, attributes, { properties }); - expect(client.getTreatmentsWithConfig).toHaveReturnedWith(treatments); + expect(client.getTreatmentsWithConfig).toHaveReturnedWith(treatments!); expect(client.getTreatmentsWithConfigByFlagSets).toBeCalledWith(flagSets, attributes, { properties }); - expect(client.getTreatmentsWithConfigByFlagSets).toHaveReturnedWith(treatmentsByFlagSets); + expect(client.getTreatmentsWithConfigByFlagSets).toHaveReturnedWith(treatmentsByFlagSets!); }); test('returns the treatments from a new client given a splitKey, and re-evaluates on SDK events.', () => { @@ -70,7 +68,7 @@ describe('useSplitTreatments', () => { render( {React.createElement(() => { - const treatments = useSplitTreatments({ names: featureFlagNames, attributes, properties, splitKey: 'user2', updateOnSdkUpdate: false }).treatments; + const treatments = useTreatmentsWithConfig({ names: featureFlagNames, attributes, properties, splitKey: 'user2', updateOnSdkUpdate: false }).treatments; renderTimes++; switch (renderTimes) { @@ -104,8 +102,8 @@ describe('useSplitTreatments', () => { expect(() => { render( React.createElement(() => { - useSplitTreatments({ names: featureFlagNames, attributes }).treatments; - useSplitTreatments({ flagSets: featureFlagNames }).treatments; + useTreatmentsWithConfig({ names: featureFlagNames, attributes }).treatments; + useTreatmentsWithConfig({ flagSets: featureFlagNames }).treatments; return null; }) ); @@ -113,40 +111,37 @@ describe('useSplitTreatments', () => { }); /** - * Input validation. Passing invalid feature flag names or attributes while the Sdk - * is not ready doesn't emit errors, and logs meaningful messages instead. + * Input validation: sanitize invalid feature flag names and return control while the SDK is not ready. */ - test('Input validation: invalid "names" and "attributes" params in useSplitTreatments.', () => { + test('Input validation: invalid names are sanitized.', () => { render( { React.createElement(() => { // @ts-expect-error Test error handling - let treatments = useSplitTreatments('split1').treatments; + let treatments = useTreatmentsWithConfig('split1').treatments; expect(treatments).toEqual({}); // @ts-expect-error Test error handling - treatments = useSplitTreatments({ names: [true] }).treatments; - expect(treatments).toEqual({}); + treatments = useTreatmentsWithConfig({ names: [true, ' flag_1 ', ' '] }).treatments; + expect(treatments).toEqual({ flag_1: CONTROL_WITH_CONFIG }); 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.'); }); - test('useSplitTreatments must update on SDK events', async () => { + test('must update on SDK events', async () => { const outerFactory = SplitFactory(sdkBrowser); const mainClient = outerFactory.client() as any; const user2Client = outerFactory.client('user_2') as any; - let countSplitContext = 0, countUseSplitTreatments = 0, countUseSplitTreatmentsUser2 = 0, countUseSplitTreatmentsUser2WithoutUpdate = 0; + let countSplitContext = 0, countUseTreatmentsWithConfig = 0, countUseTreatmentsWithConfigUser2 = 0, countUseTreatmentsWithConfigUser2WithoutUpdate = 0; const lastUpdateSetUser2 = new Set(); const lastUpdateSetUser2WithUpdate = new Set(); - function validateTreatments({ treatments, isReady, isReadyFromCache }: ISplitTreatmentsChildProps) { + function validateTreatments({ treatments, isReady, isReadyFromCache }: IUseTreatmentsWithConfigResult) { if (isReady || isReadyFromCache) { expect(treatments).toEqual({ split_test: { @@ -171,26 +166,26 @@ describe('useSplitTreatments', () => { {() => countSplitContext++} {React.createElement(() => { - const context = useSplitTreatments({ names: ['split_test'], attributes: { att1: 'att1' } }); + const context = useTreatmentsWithConfig({ names: ['split_test'], attributes: { att1: 'att1' } }); expect(context.client).toBe(mainClient); // Assert that the main client was retrieved. validateTreatments(context); - countUseSplitTreatments++; + countUseTreatmentsWithConfig++; return null; })} {React.createElement(() => { - const context = useSplitTreatments({ names: ['split_test'], splitKey: 'user_2' }); + const context = useTreatmentsWithConfig({ names: ['split_test'], splitKey: 'user_2' }); expect(context.client).toBe(user2Client); validateTreatments(context); lastUpdateSetUser2.add(context.lastUpdate); - countUseSplitTreatmentsUser2++; + countUseTreatmentsWithConfigUser2++; return null; })} {React.createElement(() => { - const context = useSplitTreatments({ names: ['split_test'], splitKey: 'user_2', updateOnSdkUpdate: false }); + const context = useTreatmentsWithConfig({ names: ['split_test'], splitKey: 'user_2', updateOnSdkUpdate: false }); expect(context.client).toBe(user2Client); validateTreatments(context); lastUpdateSetUser2WithUpdate.add(context.lastUpdate); - countUseSplitTreatmentsUser2WithoutUpdate++; + countUseTreatmentsWithConfigUser2WithoutUpdate++; return null; })} @@ -207,36 +202,46 @@ describe('useSplitTreatments', () => { // SplitFactoryProvider renders once expect(countSplitContext).toEqual(1); - // If useSplitTreatments evaluates with the main client and have default update options, it re-renders for each main client event. - expect(countUseSplitTreatments).toEqual(4); + // If useTreatmentsWithConfig evaluates with the main client and have default update options, it re-renders for each main client event. + expect(countUseTreatmentsWithConfig).toEqual(4); expect(mainClient.getTreatmentsWithConfig).toHaveBeenCalledTimes(3); // when ready from cache, ready and update expect(mainClient.getTreatmentsWithConfig).toHaveBeenLastCalledWith(['split_test'], { att1: 'att1' }, undefined); - // If useSplitTreatments evaluates with a different client and have default update options, it re-renders for each event of the new client. - expect(countUseSplitTreatmentsUser2).toEqual(4); + // If useTreatmentsWithConfig evaluates with a different client and have default update options, it re-renders for each event of the new client. + expect(countUseTreatmentsWithConfigUser2).toEqual(4); expect(lastUpdateSetUser2.size).toEqual(4); // If it is used with `updateOnSdkUpdate: false`, it doesn't render when the client emits an SDK_UPDATE event. - expect(countUseSplitTreatmentsUser2WithoutUpdate).toEqual(3); + expect(countUseTreatmentsWithConfigUser2WithoutUpdate).toEqual(3); expect(lastUpdateSetUser2WithUpdate.size).toEqual(3); expect(user2Client.getTreatmentsWithConfig).toHaveBeenCalledTimes(5); // when ready from cache x2, ready x2 and update x1 expect(user2Client.getTreatmentsWithConfig).toHaveBeenLastCalledWith(['split_test'], undefined, undefined); }); - test('ignores flagSets and logs a warning if both names and flagSets params are provided.', () => { + test('ignores flagSets if both names and flagSets params are provided.', () => { render( { React.createElement(() => { // @ts-expect-error names and flagSets are mutually exclusive - const treatments = useSplitTreatments({ names: featureFlagNames, flagSets, attributes }).treatments; + const treatments = useTreatmentsWithConfig({ names: featureFlagNames, flagSets, attributes }).treatments; expect(treatments).toEqual({ split1: CONTROL_WITH_CONFIG }); return null; }) } ); + }); - expect(logSpy).toHaveBeenLastCalledWith('[WARN] Both names and flagSets properties were provided. flagSets will be ignored.'); + test('returns fallback treatments if the client is not operational', () => { + render( + + {React.createElement(() => { + const { treatments } = useTreatmentsWithConfig({ names: ['ff1', 'ff2'], attributes, properties }); + expect(treatments).toEqual({ ff1: { treatment: 'control_ff1', config: 'control_ff1_config' }, ff2: { treatment: 'control_global', config: null } }); + return null; + })} + + ); }); }); diff --git a/src/__tests__/utils.test.ts b/src/__tests__/utils.test.ts index 3d7e32e..355c88c 100644 --- a/src/__tests__/utils.test.ts +++ b/src/__tests__/utils.test.ts @@ -1,19 +1,61 @@ -import { CONTROL_WITH_CONFIG } from '../constants'; -import { getControlTreatmentsWithConfig } from '../utils'; +import { CONTROL, CONTROL_WITH_CONFIG } from '../constants'; +import { getTreatments, getTreatment } from '../utils'; +import { sdkBrowserWithConfig } from './testUtils/sdkConfigs'; -describe('getControlTreatmentsWithConfig', () => { +const factoryWithoutFallbacks = { + settings: {} +} as SplitIO.IBrowserSDK; + +const factoryWithFallbacks = { + settings: sdkBrowserWithConfig +} as SplitIO.IBrowserSDK + +describe('getTreatments', () => { it('should return an empty object if an empty array is provided', () => { - expect(Object.values(getControlTreatmentsWithConfig([])).length).toBe(0); + expect(getTreatments([], true)).toEqual({}); + expect(getTreatments([], false)).toEqual({}); }); - it('should return an empty object if an empty array is provided', () => { + it('should return an object with control treatments if an array of feature flag names is provided', () => { const featureFlagNames = ['split1', 'split2']; - const treatments: SplitIO.TreatmentsWithConfig = getControlTreatmentsWithConfig(featureFlagNames); - featureFlagNames.forEach((featureFlagName) => { - expect(treatments[featureFlagName]).toBe(CONTROL_WITH_CONFIG); - }); - expect(Object.keys(treatments).length).toBe(featureFlagNames.length); + const treatmentsWithConfig: SplitIO.TreatmentsWithConfig = getTreatments(featureFlagNames, true); + expect(treatmentsWithConfig).toEqual({ 'split1': CONTROL_WITH_CONFIG, 'split2': CONTROL_WITH_CONFIG }); + + const treatments: SplitIO.Treatments = getTreatments(featureFlagNames, false); + expect(treatments).toEqual({ 'split1': CONTROL, 'split2': CONTROL }); + + expect(getTreatments(featureFlagNames, true, factoryWithoutFallbacks)).toEqual({ 'split1': CONTROL_WITH_CONFIG, 'split2': CONTROL_WITH_CONFIG }); + expect(getTreatments(featureFlagNames, false, factoryWithoutFallbacks)).toEqual({ 'split1': CONTROL, 'split2': CONTROL }); + }); + + it('should return an object with fallback or control treatments if an array of feature flag names and factory are provided', () => { + const featureFlagNames = ['split1', 'ff1']; + const treatmentsWithConfig: SplitIO.TreatmentsWithConfig = getTreatments(featureFlagNames, true, factoryWithFallbacks); + expect(treatmentsWithConfig).toEqual({ 'split1': { treatment: 'control_global', config: null }, 'ff1': { treatment: 'control_ff1', config: 'control_ff1_config' } }); + + const treatments: SplitIO.Treatments = getTreatments(featureFlagNames, false, factoryWithFallbacks); + expect(treatments).toEqual({ 'split1': 'control_global', 'ff1': 'control_ff1' }); + }); + +}); + +describe('getTreatment', () => { + + it('should return control treatments', () => { + expect(getTreatment('any', true)).toEqual(CONTROL_WITH_CONFIG); + expect(getTreatment('any', false)).toEqual(CONTROL); + + expect(getTreatment('any', true, factoryWithoutFallbacks)).toEqual(CONTROL_WITH_CONFIG); + expect(getTreatment('any', false, factoryWithoutFallbacks)).toEqual(CONTROL); + }); + + it('should return fallback treatments if a factory with fallback treatments is provided', () => { + const treatmentWithConfig: SplitIO.TreatmentWithConfig = getTreatment('split1', true, factoryWithFallbacks); + expect(treatmentWithConfig).toEqual({ treatment: 'control_global', config: null }); + + const treatment: SplitIO.Treatment = getTreatment('ff1', false, factoryWithFallbacks); + expect(treatment).toEqual('control_ff1' ); }); }); diff --git a/src/__tests__/withSplitFactory.test.tsx b/src/__tests__/withSplitFactory.test.tsx index 95ad025..bcf1b24 100644 --- a/src/__tests__/withSplitFactory.test.tsx +++ b/src/__tests__/withSplitFactory.test.tsx @@ -40,7 +40,7 @@ describe('withSplitFactory', () => { 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]); + expect([isReady, isReadyFromCache, hasTimedout, isTimedout, isDestroyed, lastUpdate]).toStrictEqual([true, true, false, false, false, 0]); return null; } ); diff --git a/src/__tests__/withSplitTreatments.test.tsx b/src/__tests__/withSplitTreatments.test.tsx index af178f2..88e1dd9 100644 --- a/src/__tests__/withSplitTreatments.test.tsx +++ b/src/__tests__/withSplitTreatments.test.tsx @@ -14,14 +14,14 @@ import { INITIAL_STATUS } from './testUtils/utils'; import { withSplitFactory } from '../withSplitFactory'; import { withSplitClient } from '../withSplitClient'; import { withSplitTreatments } from '../withSplitTreatments'; -import { getControlTreatmentsWithConfig } from '../utils'; +import { getTreatments } from '../utils'; const featureFlagNames = ['split1', 'split2']; 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", + In this test, the value of "props.treatments" is obtained by the function "getTreatments", and not "client.getTreatmentsWithConfig" since the client is not ready.`, () => { const Component = withSplitFactory(sdkBrowser)<{ outerProp1: string, outerProp2: number }>( @@ -36,7 +36,7 @@ describe('withSplitTreatments', () => { ...INITIAL_STATUS, factory: factory, client: clientMock, outerProp1: 'outerProp1', outerProp2: 2, - treatments: getControlTreatmentsWithConfig(featureFlagNames), + treatments: getTreatments(featureFlagNames, true), }); return null; diff --git a/src/constants.ts b/src/constants.ts index b6716d0..1db7bd4 100644 --- a/src/constants.ts +++ b/src/constants.ts @@ -14,8 +14,6 @@ export const CONTROL_WITH_CONFIG: SplitIO.TreatmentWithConfig = { }; // Warning and error messages -export const WARN_SF_CONFIG_AND_FACTORY: string = '[WARN] Both a config and factory props were provided to SplitFactoryProvider. Config prop will be ignored.'; +export const WARN_SF_CONFIG_AND_FACTORY: string = 'Both a config and factory props were provided to SplitFactoryProvider. Config prop will be ignored.'; export const EXCEPTION_NO_SFP: string = 'No SplitContext was set. Please ensure the component is wrapped in a SplitFactoryProvider.'; - -export const WARN_NAMES_AND_FLAGSETS: string = '[WARN] Both names and flagSets properties were provided. flagSets will be ignored.'; diff --git a/src/index.ts b/src/index.ts index 431fe6e..cc69202 100644 --- a/src/index.ts +++ b/src/index.ts @@ -13,6 +13,10 @@ export { SplitFactoryProvider } from './SplitFactoryProvider'; // Hooks export { useTrack } from './useTrack'; +export { useTreatment } from './useTreatment'; +export { useTreatments } from './useTreatments'; +export { useTreatmentWithConfig } from './useTreatmentWithConfig'; +export { useTreatmentsWithConfig } from './useTreatmentsWithConfig'; export { useSplitClient } from './useSplitClient'; export { useSplitTreatments } from './useSplitTreatments'; export { useSplitManager } from './useSplitManager'; @@ -34,5 +38,11 @@ export type { IUpdateProps, IUseSplitClientOptions, IUseSplitTreatmentsOptions, - IUseSplitManagerResult + IUseSplitManagerResult, + IUseTreatmentOptions, + IUseTreatmentsOptions, + IUseTreatmentResult, + IUseTreatmentWithConfigResult, + IUseTreatmentsResult, + IUseTreatmentsWithConfigResult } from './types'; diff --git a/src/types.ts b/src/types.ts index 9493cd5..04d902b 100644 --- a/src/types.ts +++ b/src/types.ts @@ -1,45 +1,10 @@ import type { ReactNode } from 'react'; +// @TODO: remove in next major release (it duplicates SplitIO.ReadinessStatus) /** - * Split Status interface. It represents the readiness state of an SDK client. + * Readiness Status interface. It represents the readiness state of an SDK client. */ -export interface ISplitStatus { - - /** - * `isReady` indicates if the Split SDK client has triggered an `SDK_READY` event and thus is ready to be consumed. - */ - isReady: boolean; - - /** - * `isReadyFromCache` indicates if the Split SDK client has triggered an `SDK_READY_FROM_CACHE` event and thus is ready to be consumed, - * although the data in cache might be stale. - */ - isReadyFromCache: boolean; - - /** - * `isTimedout` indicates if the Split SDK client has triggered an `SDK_READY_TIMED_OUT` event and is not ready to be consumed. - * In other words, `isTimedout` is equivalent to `hasTimedout && !isReady`. - */ - isTimedout: boolean; - - /** - * `hasTimedout` indicates if the Split SDK client has ever triggered an `SDK_READY_TIMED_OUT` event. - * It's meant to keep a reference that the SDK emitted a timeout at some point, not the current state. - */ - hasTimedout: boolean; - - /** - * `isDestroyed` indicates if the Split SDK client has been destroyed. - */ - isDestroyed: boolean; - - /** - * `lastUpdate` indicates the timestamp of the most recent status event. This timestamp is only updated for events that are being listened to, - * configured via the `updateOnSdkReady` option for `SDK_READY` event, `updateOnSdkReadyFromCache` for `SDK_READY_FROM_CACHE` event, - * `updateOnSdkTimedout` for `SDK_READY_TIMED_OUT` event, and `updateOnSdkUpdate` for `SDK_UPDATE` event. - */ - lastUpdate: number; -} +export interface ISplitStatus extends SplitIO.ReadinessStatus {} /** * Update Props interface. It defines the props used to configure what SDK events are listened to update the component. @@ -170,6 +135,9 @@ export interface ISplitClientProps extends IUseSplitClientOptions { children: ((props: ISplitClientChildProps) => ReactNode) | ReactNode; } +/** + * Result of the `useSplitManager` hook. + */ export interface IUseSplitManagerResult extends ISplitContextValues { /** * Split manager instance. @@ -179,6 +147,17 @@ export interface IUseSplitManagerResult extends ISplitContextValues { manager?: SplitIO.IManager; } +type EvaluationOptions = SplitIO.EvaluationOptions & { + + /** + * An object of type Attributes used to evaluate the feature flags. + */ + attributes?: SplitIO.Attributes; +} + +/** + * @deprecated `useSplitTreatments` will be removed in a future major release. We recommend replacing it with the `useTreatment*` hooks. + */ export type GetTreatmentsOptions = ({ /** @@ -193,27 +172,51 @@ export type GetTreatmentsOptions = ({ */ flagSets: string[]; names?: undefined; -}) & { +}) & EvaluationOptions; - /** - * An object of type Attributes used to evaluate the feature flags. - */ - attributes?: SplitIO.Attributes; +/** + * Options object accepted by the `useSplitTreatments` hook, used to call `client.getTreatmentsWithConfig()`, or `client.getTreatmentsWithConfigByFlagSets()`, + * depending on whether `names` or `flagSets` options are provided, and to retrieve the result along with the Split context. + * + * @deprecated `useSplitTreatments` will be removed in a future major release. We recommend replacing it with the `useTreatment*` hooks. + */ +export type IUseSplitTreatmentsOptions = GetTreatmentsOptions & IUseSplitClientOptions; + +/** + * Options object accepted by the `useTreatment` and `useTreatmentWithConfig` hooks. + */ +export type IUseTreatmentOptions = { /** - * Optional properties to append to the generated impression object sent to Split backend. + * Feature flag name to evaluate. */ - properties?: SplitIO.Properties; -} + name: string; +} & EvaluationOptions & IUseSplitClientOptions; + /** - * Options object accepted by the `useSplitTreatments` hook, used to call `client.getTreatmentsWithConfig()`, or `client.getTreatmentsWithConfigByFlagSets()`, - * depending on whether `names` or `flagSets` options are provided, and to retrieve the result along with the Split context. + * Options object accepted by the `useTreatments` and `useTreatmentsWithConfig` hooks. */ -export type IUseSplitTreatmentsOptions = GetTreatmentsOptions & IUseSplitClientOptions; +export type IUseTreatmentsOptions = ({ + + /** + * List of feature flag names to evaluate. Either this or the `flagSets` property must be provided. If both are provided, the `flagSets` option is ignored. + */ + names: string[]; + flagSets?: undefined; +} | { + + /** + * List of feature flag sets to evaluate. Either this or the `names` property must be provided. If both are provided, the `flagSets` option is ignored. + */ + flagSets: string[]; + names?: undefined; +}) & EvaluationOptions & IUseSplitClientOptions; /** * SplitTreatments Child Props interface. These are the props that the child component receives from the 'SplitTreatments' component. + * + * @deprecated `SplitTreatments` will be removed in a future major release. We recommend replacing it with the `useTreatments*` hooks. */ export interface ISplitTreatmentsChildProps extends ISplitContextValues { @@ -231,9 +234,68 @@ export interface ISplitTreatmentsChildProps extends ISplitContextValues { treatments: SplitIO.TreatmentsWithConfig; } +/** + * Result of the `useTreatment` hook. + */ +export interface IUseTreatmentResult extends ISplitContextValues { + /** + * The treatment string for a feature flag, returned by client.getTreatment(). + */ + treatment: SplitIO.Treatment; +} + +/** + * Result of the `useTreatmentWithConfig` hook. + */ +export interface IUseTreatmentWithConfigResult extends ISplitContextValues { + /** + * The treatment with config for a feature flag, returned by client.getTreatmentWithConfig(). + */ + treatment: SplitIO.TreatmentWithConfig; +} + +/** + * Result of the `useTreatments` hook. + */ +export interface IUseTreatmentsResult extends ISplitContextValues { + /** + * An object with the treatment strings for a bulk of feature flags, returned by client.getTreatments() or client.getTreatmentsByFlagSets(). + * For example: + * + * ```js + * { + * feature1: 'on', + * feature2: 'off' + * } + * ``` + */ + treatments: SplitIO.Treatments; +} + +/** + * Result of the `useTreatmentsWithConfig` hook. + */ +export interface IUseTreatmentsWithConfigResult extends ISplitContextValues { + + /** + * An object with the treatments with configs for a bulk of feature flags, returned by client.getTreatmentsWithConfig() or client.getTreatmentsWithConfigByFlagSets(). + * Each existing configuration is a stringified version of the JSON you defined on the Split user interface. For example: + * + * ```js + * { + * feature1: { treatment: 'on', config: null }, + * feature2: { treatment: 'off', config: '{"bannerText":"Click here."}' } + * } + * ``` + */ + 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. + * + * @deprecated `SplitTreatments` will be removed in a future major release. We recommend replacing it with the `useTreatments*` hooks. */ export type ISplitTreatmentsProps = IUseSplitTreatmentsOptions & { diff --git a/src/useSplitTreatments.ts b/src/useSplitTreatments.ts index 5b672f0..4d7f595 100644 --- a/src/useSplitTreatments.ts +++ b/src/useSplitTreatments.ts @@ -1,7 +1,5 @@ -import * as React from 'react'; -import { memoizeGetTreatmentsWithConfig } from './utils'; import { ISplitTreatmentsChildProps, IUseSplitTreatmentsOptions } from './types'; -import { useSplitClient } from './useSplitClient'; +import { useTreatmentsWithConfig } from '.'; /** * `useSplitTreatments` is a hook that returns an Split Context object extended with a `treatments` property object that contains feature flag evaluations. @@ -17,20 +15,9 @@ import { useSplitClient } from './useSplitClient'; * ``` * * @see {@link https://developer.harness.io/docs/feature-management-experimentation/sdks-and-infrastructure/client-side-sdks/javascript-sdk/#get-treatments-with-configurations} + * + * @deprecated `useSplitTreatments` will be removed in a future major release. We recommend replacing it with the `useTreatment*` hooks. */ export function useSplitTreatments(options: IUseSplitTreatmentsOptions): ISplitTreatmentsChildProps { - const context = useSplitClient({ ...options, attributes: undefined }); - const { client, lastUpdate } = context; - const { names, flagSets, attributes, properties } = options; - - const getTreatmentsWithConfig = React.useMemo(memoizeGetTreatmentsWithConfig, []); - - // Shallow copy `client.getAttributes` result for memoization, as it returns the same reference unless `client.clearAttributes` is invoked. - // Note: the same issue occurs with the `names` and `attributes` arguments if they are mutated directly by the user instead of providing a new object. - const treatments = getTreatmentsWithConfig(client, lastUpdate, names, attributes, client ? { ...client.getAttributes() } : {}, flagSets, properties && { properties }); - - return { - ...context, - treatments, - }; + return useTreatmentsWithConfig(options); } diff --git a/src/useTreatment.ts b/src/useTreatment.ts new file mode 100644 index 0000000..19daa8b --- /dev/null +++ b/src/useTreatment.ts @@ -0,0 +1,46 @@ +import * as React from 'react'; +import memoizeOne from 'memoize-one'; +import { argsAreEqual, getTreatment } from './utils'; +import { IUseTreatmentResult, IUseTreatmentOptions } from './types'; +import { useSplitClient } from './useSplitClient'; + +function evaluateFeatureFlag(client: SplitIO.IBrowserClient | undefined, _lastUpdate: number, names: string[], attributes?: SplitIO.Attributes, _clientAttributes?: SplitIO.Attributes, _flagSets?: undefined, options?: SplitIO.EvaluationOptions, factory?: SplitIO.IBrowserSDK) { + return client && client.getStatus().isOperational ? + client.getTreatment(names[0], attributes, options) : + getTreatment(names[0], false, factory); +} + +function memoizeGetTreatment() { + return memoizeOne(evaluateFeatureFlag, argsAreEqual); +} + +/** + * `useTreatment` is a hook that returns an Split Context object extended with a `treatment` property. + * It uses the `useSplitClient` hook to access the client, and invokes the `client.getTreatment()` method. + * + * @param options - An options object with a feature flag name to evaluate, and an optional `attributes` and `splitKey` values to configure the client. + * @returns A Split Context object extended with a Treatment instance, that might be a control treatment if the client is not available or ready, or if the provided feature flag name does not exist. + * + * @example + * ```js + * const { treatment, isReady, isReadyFromCache, hasTimedout, lastUpdate, ... } = useTreatment({ name: 'feature_1'}); + * ``` + * + * @see {@link https://developer.harness.io/docs/feature-management-experimentation/sdks-and-infrastructure/client-side-sdks/javascript-sdk/#multiple-evaluations-at-once} + */ +export function useTreatment(options: IUseTreatmentOptions): IUseTreatmentResult { + const context = useSplitClient({ ...options, attributes: undefined }); + const { factory, client, lastUpdate } = context; + const { name, attributes, properties } = options; + + const getTreatment = React.useMemo(memoizeGetTreatment, []); + + // Shallow copy `client.getAttributes` result for memoization, as it returns the same reference unless `client.clearAttributes` is invoked. + // Note: the same issue occurs with the `names` and `attributes` arguments if they are mutated directly by the user instead of providing a new object. + const treatment = getTreatment(client, lastUpdate, [name], attributes, client ? { ...client.getAttributes() } : {}, undefined, properties && { properties }, factory); + + return { + ...context, + treatment, + }; +} diff --git a/src/useTreatmentWithConfig.ts b/src/useTreatmentWithConfig.ts new file mode 100644 index 0000000..0fecfac --- /dev/null +++ b/src/useTreatmentWithConfig.ts @@ -0,0 +1,46 @@ +import * as React from 'react'; +import memoizeOne from 'memoize-one'; +import { argsAreEqual, getTreatment } from './utils'; +import { IUseTreatmentWithConfigResult, IUseTreatmentOptions } from './types'; +import { useSplitClient } from './useSplitClient'; + +function evaluateFeatureFlagWithConfig(client: SplitIO.IBrowserClient | undefined, _lastUpdate: number, names: string[], attributes?: SplitIO.Attributes, _clientAttributes?: SplitIO.Attributes, _flagSets?: undefined, options?: SplitIO.EvaluationOptions, factory?: SplitIO.IBrowserSDK) { + return client && client.getStatus().isOperational ? + client.getTreatmentWithConfig(names[0], attributes, options) : + getTreatment(names[0], true, factory); +} + +function memoizeGetTreatmentWithConfig() { + return memoizeOne(evaluateFeatureFlagWithConfig, argsAreEqual); +} + +/** + * `useTreatmentWithConfig` is a hook that returns an Split Context object extended with a `treatment` property. + * It uses the `useSplitClient` hook to access the client, and invokes the `client.getTreatmentWithConfig()` method. + * + * @param options - An options object with a feature flag name to evaluate, and an optional `attributes` and `splitKey` values to configure the client. + * @returns A Split Context object extended with a TreatmentWithConfig instance, that might be a control treatment if the client is not available or ready, or if the provided feature flag name does not exist. + * + * @example + * ```js + * const { treatment: { treatment, config }, isReady, isReadyFromCache, hasTimedout, lastUpdate, ... } = useTreatmentWithConfig({ name: 'feature_1'}); + * ``` + * + * @see {@link https://developer.harness.io/docs/feature-management-experimentation/sdks-and-infrastructure/client-side-sdks/javascript-sdk/#get-treatments-with-configurations} + */ +export function useTreatmentWithConfig(options: IUseTreatmentOptions): IUseTreatmentWithConfigResult { + const context = useSplitClient({ ...options, attributes: undefined }); + const { factory, client, lastUpdate } = context; + const { name, attributes, properties } = options; + + const getTreatmentWithConfig = React.useMemo(memoizeGetTreatmentWithConfig, []); + + // Shallow copy `client.getAttributes` result for memoization, as it returns the same reference unless `client.clearAttributes` is invoked. + // Note: the same issue occurs with the `names` and `attributes` arguments if they are mutated directly by the user instead of providing a new object. + const treatment = getTreatmentWithConfig(client, lastUpdate, [name], attributes, client ? { ...client.getAttributes() } : {}, undefined, properties && { properties }, factory); + + return { + ...context, + treatment, + }; +} diff --git a/src/useTreatments.ts b/src/useTreatments.ts new file mode 100644 index 0000000..1cb22b3 --- /dev/null +++ b/src/useTreatments.ts @@ -0,0 +1,51 @@ +import * as React from 'react'; +import memoizeOne from 'memoize-one'; +import { argsAreEqual, getTreatments } from './utils'; +import { IUseTreatmentsResult, IUseTreatmentsOptions } from './types'; +import { useSplitClient } from './useSplitClient'; + +function evaluateFeatureFlags(client: SplitIO.IBrowserClient | undefined, _lastUpdate: number, names?: SplitIO.SplitNames, attributes?: SplitIO.Attributes, _clientAttributes?: SplitIO.Attributes, flagSets?: string[], options?: SplitIO.EvaluationOptions, factory?: SplitIO.IBrowserSDK) { + return client && client.getStatus().isOperational && (names || flagSets) ? + names ? + client.getTreatments(names, attributes, options) : + client.getTreatmentsByFlagSets(flagSets!, attributes, options) : + names ? + getTreatments(names, false, factory) : + {} // empty object when evaluating with flag sets and client is not ready +} + +export function memoizeGetTreatments() { + return memoizeOne(evaluateFeatureFlags, argsAreEqual); +} + +/** + * `useTreatments` is a hook that returns an Split Context object extended with a `treatments` property object that contains feature flag evaluations. + * It uses the `useSplitClient` hook to access the client, and invokes the `client.getTreatments()` method if the `names` option is provided, + * or the `client.getTreatmentsByFlagSets()` method if the `flagSets` option is provided. + * + * @param options - An options object with a list of feature flag names or flag sets to evaluate, and an optional `attributes` and `splitKey` values to configure the client. + * @returns A Split Context object extended with a Treatments instance, that might contain control treatments if the client is not available or ready, or if feature flag names do not exist. + * + * @example + * ```js + * const { treatments: { feature_1, feature_2 }, isReady, isReadyFromCache, hasTimedout, lastUpdate, ... } = useTreatments({ names: ['feature_1', 'feature_2']}); + * ``` + * + * @see {@link https://developer.harness.io/docs/feature-management-experimentation/sdks-and-infrastructure/client-side-sdks/javascript-sdk/#multiple-evaluations-at-once} + */ +export function useTreatments(options: IUseTreatmentsOptions): IUseTreatmentsResult { + const context = useSplitClient({ ...options, attributes: undefined }); + const { factory, client, lastUpdate } = context; + const { names, flagSets, attributes, properties } = options; + + const getTreatments = React.useMemo(memoizeGetTreatments, []); + + // Shallow copy `client.getAttributes` result for memoization, as it returns the same reference unless `client.clearAttributes` is invoked. + // Note: the same issue occurs with the `names` and `attributes` arguments if they are mutated directly by the user instead of providing a new object. + const treatments = getTreatments(client, lastUpdate, names, attributes, client ? { ...client.getAttributes() } : {}, flagSets, properties && { properties }, factory); + + return { + ...context, + treatments, + }; +} diff --git a/src/useTreatmentsWithConfig.ts b/src/useTreatmentsWithConfig.ts new file mode 100644 index 0000000..0cbd7c3 --- /dev/null +++ b/src/useTreatmentsWithConfig.ts @@ -0,0 +1,51 @@ +import * as React from 'react'; +import memoizeOne from 'memoize-one'; +import { argsAreEqual, getTreatments } from './utils'; +import { IUseTreatmentsOptions, IUseTreatmentsWithConfigResult } from './types'; +import { useSplitClient } from './useSplitClient'; + +function evaluateFeatureFlagsWithConfig(client: SplitIO.IBrowserClient | undefined, _lastUpdate: number, names?: SplitIO.SplitNames, attributes?: SplitIO.Attributes, _clientAttributes?: SplitIO.Attributes, flagSets?: string[], options?: SplitIO.EvaluationOptions, factory?: SplitIO.IBrowserSDK) { + return client && client.getStatus().isOperational && (names || flagSets) ? + names ? + client.getTreatmentsWithConfig(names, attributes, options) : + client.getTreatmentsWithConfigByFlagSets(flagSets!, attributes, options) : + names ? + getTreatments(names, true, factory) : + {} // empty object when evaluating with flag sets and client is not ready +} + +function memoizeGetTreatmentsWithConfig() { + return memoizeOne(evaluateFeatureFlagsWithConfig, argsAreEqual); +} + +/** + * `useTreatmentsWithConfig` is a hook that returns an Split Context object extended with a `treatments` property object that contains feature flag evaluations. + * It uses the `useSplitClient` hook to access the client, and invokes the `client.getTreatmentsWithConfig()` method if the `names` option is provided, + * or the `client.getTreatmentsWithConfigByFlagSets()` method if the `flagSets` option is provided. + * + * @param options - An options object with a list of feature flag names or flag sets to evaluate, and an optional `attributes` and `splitKey` values to configure the client. + * @returns A Split Context object extended with a TreatmentsWithConfig instance, that might contain control treatments if the client is not available or ready, or if feature flag names do not exist. + * + * @example + * ```js + * const { treatments: { feature_1, feature_2 }, isReady, isReadyFromCache, hasTimedout, lastUpdate, ... } = useTreatmentsWithConfig({ names: ['feature_1', 'feature_2']}); + * ``` + * + * @see {@link https://developer.harness.io/docs/feature-management-experimentation/sdks-and-infrastructure/client-side-sdks/javascript-sdk/#get-treatments-with-configurations} + */ +export function useTreatmentsWithConfig(options: IUseTreatmentsOptions): IUseTreatmentsWithConfigResult { + const context = useSplitClient({ ...options, attributes: undefined }); + const { factory, client, lastUpdate } = context; + const { names, flagSets, attributes, properties } = options; + + const getTreatmentsWithConfig = React.useMemo(memoizeGetTreatmentsWithConfig, []); + + // Shallow copy `client.getAttributes` result for memoization, as it returns the same reference unless `client.clearAttributes` is invoked. + // Note: the same issue occurs with the `names` and `attributes` arguments if they are mutated directly by the user instead of providing a new object. + const treatments = getTreatmentsWithConfig(client, lastUpdate, names, attributes, client ? { ...client.getAttributes() } : {}, flagSets, properties && { properties }, factory); + + return { + ...context, + treatments, + }; +} diff --git a/src/utils.ts b/src/utils.ts index c78ec77..dddb3f7 100644 --- a/src/utils.ts +++ b/src/utils.ts @@ -1,34 +1,16 @@ -import memoizeOne from 'memoize-one'; import shallowEqual from 'shallowequal'; -import { CONTROL_WITH_CONFIG, WARN_NAMES_AND_FLAGSETS } from './constants'; +import { CONTROL, CONTROL_WITH_CONFIG } from './constants'; import { ISplitStatus } from './types'; -// Utils used to access singleton instances of Split factories and clients, and to gracefully shutdown all clients together. - -/** - * ClientWithContext interface. - */ -interface IClientWithContext extends SplitIO.IBrowserClient { - __getStatus(): { - isReady: boolean; - isReadyFromCache: boolean; - isTimedout: boolean; - hasTimedout: boolean; - isDestroyed: boolean; - isOperational: boolean; - lastUpdate: number; - }; +function isString(val: unknown): val is string { + return typeof val === 'string' || val instanceof String; } -export interface IFactoryWithLazyInit extends SplitIO.IBrowserSDK { - config: SplitIO.IBrowserSettings; - init(): void; -} +// Utils used to access singleton instances of Split factories and clients: -// idempotent operation -export function getSplitClient(factory: SplitIO.IBrowserSDK, key?: SplitIO.SplitKey): IClientWithContext { +export function getSplitClient(factory: SplitIO.IBrowserSDK, key?: SplitIO.SplitKey): SplitIO.IBrowserClient { // factory.client is an idempotent operation - const client = (key !== undefined ? factory.client(key) : factory.client()) as IClientWithContext; + const client = key !== undefined ? factory.client(key) : factory.client(); // Remove EventEmitter warning emitted when using multiple SDK hooks or components. // Unlike JS SDK, users don't need to access the client directly, making the warning irrelevant. @@ -37,111 +19,71 @@ export function getSplitClient(factory: SplitIO.IBrowserSDK, key?: SplitIO.Split return client; } -// Util used to get client status. -// It might be removed in the future, if the JS SDK extends its public API with a `getStatus` method export function getStatus(client?: SplitIO.IBrowserClient): ISplitStatus { - const status = client && (client as IClientWithContext).__getStatus(); - - return { - isReady: status ? status.isReady : false, - isReadyFromCache: status ? status.isReadyFromCache : false, - isTimedout: status ? status.isTimedout : false, - hasTimedout: status ? status.hasTimedout : false, - isDestroyed: status ? status.isDestroyed : false, - lastUpdate: status ? status.lastUpdate : 0, - }; + return client ? + client.getStatus() : + { + isReady: false, + isReadyFromCache: false, + isTimedout: false, + hasTimedout: false, + isDestroyed: false, + isOperational: false, + lastUpdate: 0, + }; } -/** - * Manage client attributes binding - */ +// Manage client attributes binding // @TODO should reset attributes rather than set/merge them, to keep SFP and hooks pure. export function initAttributes(client?: SplitIO.IBrowserClient, attributes?: SplitIO.Attributes) { if (client && attributes) client.setAttributes(attributes); } -// Input validation utils that will be replaced eventually - -function validateFeatureFlags(maybeFeatureFlags: unknown, listName = 'feature flag names'): false | string[] { - if (Array.isArray(maybeFeatureFlags) && maybeFeatureFlags.length > 0) { - const validatedArray: string[] = []; - // Remove invalid values - maybeFeatureFlags.forEach((maybeFeatureFlag) => { - const featureFlagName = validateFeatureFlag(maybeFeatureFlag); - if (featureFlagName) validatedArray.push(featureFlagName); - }); +// Utils used to retrieve fallback or control treatments when the client is not operational: - // Strip off duplicated values if we have valid feature flag names then return - if (validatedArray.length) return uniq(validatedArray); - } +export function getTreatment(flagName: string, withConfig: true, factory?: SplitIO.IBrowserSDK): SplitIO.TreatmentWithConfig; +export function getTreatment(flagName: string, withConfig: false, factory?: SplitIO.IBrowserSDK): SplitIO.Treatment; +export function getTreatment(flagName: string, withConfig: boolean, factory?: SplitIO.IBrowserSDK): SplitIO.Treatment | SplitIO.TreatmentWithConfig; +export function getTreatment(flagName: string, withConfig: boolean, factory?: SplitIO.IBrowserSDK) { + if (factory && factory.settings.fallbackTreatments) { + const fallbacks = factory.settings.fallbackTreatments; - console.log(`[ERROR] ${listName} must be a non-empty array.`); - return false; -} + const treatment = fallbacks.byFlag?.[flagName] || fallbacks.global; -const TRIMMABLE_SPACES_REGEX = /^[\s\uFEFF\xA0]+|[\s\uFEFF\xA0]+$/; - -function validateFeatureFlag(maybeFeatureFlag: unknown, item = 'feature flag name'): false | string { - if (maybeFeatureFlag == undefined) { - console.log(`[ERROR] you passed a null or undefined ${item}, ${item} must be a non-empty string.`); - } else if (!isString(maybeFeatureFlag)) { - console.log(`[ERROR] you passed an invalid ${item}, ${item} must be a non-empty string.`); - } else { - if (TRIMMABLE_SPACES_REGEX.test(maybeFeatureFlag)) { - console.log(`[WARN] ${item} "${maybeFeatureFlag}" has extra whitespace, trimming.`); - maybeFeatureFlag = maybeFeatureFlag.trim(); - } - - if ((maybeFeatureFlag as string).length > 0) { - return maybeFeatureFlag as string; - } else { - console.log(`[ERROR] you passed an empty ${item}, ${item} must be a non-empty string.`); + if (treatment) { + return isString(treatment) ? + withConfig ? { treatment, config: null } : treatment : + withConfig ? treatment : treatment.treatment; } } - return false; + return withConfig ? CONTROL_WITH_CONFIG : CONTROL; } -export function getControlTreatmentsWithConfig(featureFlagNames: unknown): SplitIO.TreatmentsWithConfig { - // validate featureFlags Names - const validatedFeatureFlagNames = validateFeatureFlags(featureFlagNames); +export function getTreatments(featureFlagNames: unknown, withConfig: true, factory?: SplitIO.IBrowserSDK): SplitIO.TreatmentsWithConfig; +export function getTreatments(featureFlagNames: unknown, withConfig: false, factory?: SplitIO.IBrowserSDK): SplitIO.Treatments; +export function getTreatments(featureFlagNames: unknown, withConfig: boolean, factory?: SplitIO.IBrowserSDK) { + // validate feature flag names + if (!Array.isArray(featureFlagNames)) return {}; - // return empty object if the returned value is false - if (!validatedFeatureFlagNames) return {}; + featureFlagNames = featureFlagNames + .filter((featureFlagName) => isString(featureFlagName)) + .map((featureFlagName) => featureFlagName.trim()) + .filter((featureFlagName) => featureFlagName.length > 0); - // return control treatments for each validated feature flag name - return validatedFeatureFlagNames.reduce((pValue: SplitIO.TreatmentsWithConfig, cValue: string) => { - pValue[cValue] = CONTROL_WITH_CONFIG; + // return control or fallback treatment for each validated feature flag name + return (featureFlagNames as string[]).reduce((pValue: SplitIO.Treatments | SplitIO.TreatmentsWithConfig, featureFlagName: string) => { + pValue[featureFlagName] = getTreatment(featureFlagName, withConfig, factory); return pValue; }, {}); } /** - * Removes duplicate items on an array of strings. + * Utils to memoize `client.getTreatments*` method calls to avoid duplicated impressions. + * The result treatments are the same given the same `client` instance, `lastUpdate` timestamp, and list of feature flag names and attributes. */ -function uniq(arr: string[]): string[] { - const seen: Record = {}; - return arr.filter((item) => { - return Object.prototype.hasOwnProperty.call(seen, item) ? false : seen[item] = true; - }); -} -/** - * Checks if a given value is a string. - */ -function isString(val: unknown): val is string { - return typeof val === 'string' || val instanceof String; -} - -/** - * Gets a memoized version of the `client.getTreatmentsWithConfig` method. - * It is used to avoid duplicated impressions, because the result treatments are the same given the same `client` instance, `lastUpdate` timestamp, and list of feature flag `names` and `attributes`. - */ -export function memoizeGetTreatmentsWithConfig() { - return memoizeOne(evaluateFeatureFlags, argsAreEqual); -} - -function argsAreEqual(newArgs: any[], lastArgs: any[]): boolean { +export function argsAreEqual(newArgs: any[], lastArgs: any[]): boolean { return newArgs[0] === lastArgs[0] && // client newArgs[1] === lastArgs[1] && // lastUpdate shallowEqual(newArgs[2], lastArgs[2]) && // names @@ -149,15 +91,3 @@ function argsAreEqual(newArgs: any[], lastArgs: any[]): boolean { shallowEqual(newArgs[4], lastArgs[4]) && // client attributes shallowEqual(newArgs[5], lastArgs[5]); // flagSets } - -function evaluateFeatureFlags(client: SplitIO.IBrowserClient | undefined, _lastUpdate: number, names?: SplitIO.SplitNames, attributes?: SplitIO.Attributes, _clientAttributes?: SplitIO.Attributes, flagSets?: string[], options?: SplitIO.EvaluationOptions) { - if (names && flagSets) console.log(WARN_NAMES_AND_FLAGSETS); - - return client && (client as IClientWithContext).__getStatus().isOperational && (names || flagSets) ? - names ? - client.getTreatmentsWithConfig(names, attributes, options) : - client.getTreatmentsWithConfigByFlagSets(flagSets!, attributes, options) : - names ? - getControlTreatmentsWithConfig(names) : - {} // empty object when evaluating with flag sets and client is not ready -} diff --git a/src/withSplitTreatments.tsx b/src/withSplitTreatments.tsx index 8119292..54bf4fa 100644 --- a/src/withSplitTreatments.tsx +++ b/src/withSplitTreatments.tsx @@ -10,7 +10,7 @@ import { SplitTreatments } from './SplitTreatments'; * @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. + * @deprecated `withSplitTreatments` will be removed in a future major release. We recommend replacing it with the `useTreatment*` hooks. */ export function withSplitTreatments(names: string[], attributes?: SplitIO.Attributes) { diff --git a/umd.ts b/umd.ts index 351c5d3..d9a5dcb 100644 --- a/umd.ts +++ b/umd.ts @@ -3,6 +3,7 @@ import { withSplitFactory, withSplitClient, withSplitTreatments, SplitFactoryProvider, SplitClient, SplitTreatments, useSplitClient, useSplitTreatments, useTrack, useSplitManager, + useTreatment, useTreatments, useTreatmentWithConfig, useTreatmentsWithConfig, SplitContext, } from './src/index'; @@ -11,5 +12,6 @@ export default { withSplitFactory, withSplitClient, withSplitTreatments, SplitFactoryProvider, SplitClient, SplitTreatments, useSplitClient, useSplitTreatments, useTrack, useSplitManager, + useTreatment, useTreatments, useTreatmentWithConfig, useTreatmentsWithConfig, SplitContext, };