├── .nvmrc ├── .github ├── CODEOWNERS ├── pull_request_template.md └── workflows │ ├── update-license-year.yml │ └── ci-cd.yml ├── .gitignore ├── webpack.prod.js ├── webpack.dev.js ├── src ├── __tests__ │ ├── testUtils │ │ ├── sdkConfigs.ts │ │ ├── utils.tsx │ │ └── mockSplitFactory.ts │ ├── SplitContext.test.tsx │ ├── withSplitTreatments.test.tsx │ ├── utils.test.ts │ ├── index.test.ts │ ├── withSplitFactory.test.tsx │ ├── useSplitManager.test.tsx │ ├── withSplitClient.test.tsx │ ├── useTrack.test.tsx │ ├── useTreatment.test.tsx │ ├── SplitFactoryProvider.test.tsx │ ├── useTreatmentWithConfig.test.tsx │ ├── useTreatments.test.tsx │ ├── useTreatmentsWithConfig.test.tsx │ ├── useSplitClient.test.tsx │ └── SplitTreatments.test.tsx ├── constants.ts ├── SplitContext.ts ├── SplitClient.tsx ├── SplitTreatments.tsx ├── useTrack.ts ├── useSplitManager.ts ├── useSplitTreatments.ts ├── withSplitClient.tsx ├── index.ts ├── withSplitTreatments.tsx ├── withSplitFactory.tsx ├── useTreatment.ts ├── useTreatmentWithConfig.ts ├── useTreatments.ts ├── useTreatmentsWithConfig.ts ├── SplitFactoryProvider.tsx ├── useSplitClient.ts ├── utils.ts └── types.ts ├── tsconfig.jest.json ├── jest.config.js ├── LICENSE ├── umd.ts ├── webpack.common.js ├── tsconfig.json ├── .eslintrc.js ├── CONTRIBUTORS-GUIDE.md ├── package.json ├── README.md └── MIGRATION-GUIDE.md /.nvmrc: -------------------------------------------------------------------------------- 1 | lts/* 2 | -------------------------------------------------------------------------------- /.github/CODEOWNERS: -------------------------------------------------------------------------------- 1 | * @splitio/sdk 2 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | .DS_Store 2 | /.vscode 3 | /node_modules 4 | /cjs 5 | /esm 6 | /umd 7 | /types 8 | /coverage 9 | .scannerwork 10 | -------------------------------------------------------------------------------- /.github/pull_request_template.md: -------------------------------------------------------------------------------- 1 | # React SDK 2 | 3 | ## What did you accomplish? 4 | 5 | ## How do we test the changes introduced in this PR? 6 | 7 | ## Extra Notes 8 | -------------------------------------------------------------------------------- /webpack.prod.js: -------------------------------------------------------------------------------- 1 | const { merge } = require('webpack-merge'); 2 | const common = require('./webpack.common.js'); 3 | const pkg = require('./package.json'); 4 | 5 | const VERSION = pkg.version; 6 | 7 | module.exports = env => merge(common, { 8 | mode: 'production', 9 | output: { 10 | filename: `[name]${env.branch !== 'main' ? '-dev-' + VERSION : '-' + VERSION}.min.js` 11 | } 12 | }); 13 | -------------------------------------------------------------------------------- /webpack.dev.js: -------------------------------------------------------------------------------- 1 | const { merge } = require('webpack-merge'); 2 | const common = require('./webpack.common.js'); 3 | const pkg = require('./package.json'); 4 | 5 | const VERSION = pkg.version; 6 | 7 | module.exports = env => merge(common, { 8 | mode: 'development', 9 | devtool: 'source-map', 10 | output: { 11 | filename: `[name]${env.branch !== 'main' ? '-dev-' + VERSION : '-' + VERSION}.js` 12 | } 13 | }); 14 | -------------------------------------------------------------------------------- /src/__tests__/testUtils/sdkConfigs.ts: -------------------------------------------------------------------------------- 1 | export const sdkBrowser: SplitIO.IBrowserSettings = { 2 | core: { 3 | authorizationKey: 'sdk-key', 4 | key: 'customer-key', 5 | }, 6 | }; 7 | 8 | export const sdkBrowserWithConfig: SplitIO.IBrowserSettings = { 9 | ...sdkBrowser, 10 | fallbackTreatments: { 11 | global: 'control_global', 12 | byFlag: { ff1: { treatment: 'control_ff1', config: 'control_ff1_config' } } 13 | } 14 | }; 15 | -------------------------------------------------------------------------------- /tsconfig.jest.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "target": "es5", 4 | "module": "commonjs", 5 | "jsx": "react", 6 | "lib": [ 7 | "es2015", 8 | "dom" 9 | ], 10 | "strict": false, 11 | "sourceMap": true, 12 | "esModuleInterop": true, 13 | "strictNullChecks": false, 14 | "resolveJsonModule": true 15 | }, 16 | "include": [ 17 | "src/**/*" 18 | ], 19 | "exclude": [ 20 | "node_modules" 21 | ] 22 | } 23 | -------------------------------------------------------------------------------- /jest.config.js: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | preset: 'ts-jest', 3 | 4 | testEnvironment: 'jsdom', 5 | 6 | globals: { 7 | 'ts-jest': { 8 | tsconfig: 'tsconfig.jest.json', 9 | } 10 | }, 11 | 12 | // Test files are .js, .jsx, .ts and .tsx files inside of __tests__ folders and with a suffix of .test or .spec 13 | testMatch: ['**/__tests__/**/?(*.)+(spec|test).[jt]s?(x)'], 14 | 15 | // Included files for test coverage (npm run test:coverage) 16 | collectCoverageFrom: [ 17 | 'src/**/*.{js,jsx,ts,tsx}', 18 | '!src/__tests__/**', 19 | ] 20 | }; 21 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | Copyright © 2025 Split Software, Inc. 2 | 3 | Licensed under the Apache License, Version 2.0 (the "License"); 4 | you may not use this file except in compliance with the License. 5 | You may obtain a copy of the License at 6 | 7 | http://www.apache.org/licenses/LICENSE-2.0 8 | 9 | Unless required by applicable law or agreed to in writing, software 10 | distributed under the License is distributed on an "AS IS" BASIS, 11 | WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 12 | See the License for the specific language governing permissions and 13 | limitations under the License. 14 | -------------------------------------------------------------------------------- /umd.ts: -------------------------------------------------------------------------------- 1 | import { 2 | SplitFactory, 3 | withSplitFactory, withSplitClient, withSplitTreatments, 4 | SplitFactoryProvider, SplitClient, SplitTreatments, 5 | useSplitClient, useSplitTreatments, useTrack, useSplitManager, 6 | useTreatment, useTreatments, useTreatmentWithConfig, useTreatmentsWithConfig, 7 | SplitContext, 8 | } from './src/index'; 9 | 10 | export default { 11 | SplitFactory, 12 | withSplitFactory, withSplitClient, withSplitTreatments, 13 | SplitFactoryProvider, SplitClient, SplitTreatments, 14 | useSplitClient, useSplitTreatments, useTrack, useSplitManager, 15 | useTreatment, useTreatments, useTreatmentWithConfig, useTreatmentsWithConfig, 16 | SplitContext, 17 | }; 18 | -------------------------------------------------------------------------------- /src/constants.ts: -------------------------------------------------------------------------------- 1 | // The string below is a marker and will be replaced by the real version number. DO NOT CHANGE 2 | export const VERSION: string = 'react-' + 'REACT_SDK_VERSION_NUMBER'; 3 | 4 | // Treatments 5 | export const ON: SplitIO.Treatment = 'on'; 6 | 7 | export const OFF: SplitIO.Treatment = 'off'; 8 | 9 | export const CONTROL: SplitIO.Treatment = 'control'; // SplitIO default value 10 | 11 | export const CONTROL_WITH_CONFIG: SplitIO.TreatmentWithConfig = { 12 | treatment: 'control', // SplitIO default value 13 | config: null, 14 | }; 15 | 16 | // Warning and error messages 17 | export const WARN_SF_CONFIG_AND_FACTORY: string = 'Both a config and factory props were provided to SplitFactoryProvider. Config prop will be ignored.'; 18 | 19 | export const EXCEPTION_NO_SFP: string = 'No SplitContext was set. Please ensure the component is wrapped in a SplitFactoryProvider.'; 20 | -------------------------------------------------------------------------------- /src/SplitContext.ts: -------------------------------------------------------------------------------- 1 | import * as React from 'react'; 2 | import { ISplitContextValues } from './types'; 3 | import { EXCEPTION_NO_SFP } from './constants'; 4 | 5 | /** 6 | * Split Context is the React Context instance provided by the SplitFactoryProvider and consumed by the Split Hooks. 7 | * It is used to share the SDK factory instance and other values across the application. 8 | */ 9 | export const SplitContext = React.createContext(undefined); 10 | 11 | /** 12 | * Hook to access the value of `SplitContext`. 13 | * 14 | * @returns The Split Context object value 15 | * @throws Throws an error if the Split Context is not set (i.e. the component is not wrapped in a SplitFactoryProvider) 16 | */ 17 | export function useSplitContext() { 18 | const context = React.useContext(SplitContext); 19 | 20 | if (!context) throw new Error(EXCEPTION_NO_SFP) 21 | 22 | return context; 23 | } 24 | -------------------------------------------------------------------------------- /src/SplitClient.tsx: -------------------------------------------------------------------------------- 1 | import * as React from 'react'; 2 | import { SplitContext } from './SplitContext'; 3 | import { ISplitClientProps } from './types'; 4 | import { useSplitClient } from './useSplitClient'; 5 | 6 | /** 7 | * SplitClient will initialize a new SDK client and listen for its events in order to update the Split Context. 8 | * Children components will have access to the new client when accessing Split Context. 9 | * 10 | * The underlying SDK client can be changed during the component lifecycle 11 | * if the component is updated with a different splitKey prop. 12 | */ 13 | export function SplitClient(props: ISplitClientProps) { 14 | const { children } = props; 15 | const context = useSplitClient(props); 16 | 17 | return ( 18 | 19 | { 20 | typeof children === 'function' ? 21 | children(context) : 22 | children 23 | } 24 | 25 | ) 26 | } 27 | -------------------------------------------------------------------------------- /webpack.common.js: -------------------------------------------------------------------------------- 1 | const path = require('path'); 2 | 3 | module.exports = { 4 | entry: { 5 | 'splitio-react': ['./umd.ts'] 6 | }, 7 | 8 | output: { 9 | path: path.resolve(__dirname, 'umd'), 10 | filename: '[name].js', 11 | library: 'splitio', 12 | libraryTarget: 'umd', 13 | libraryExport: 'default', 14 | }, 15 | 16 | resolve: { 17 | extensions: ['.ts', '.tsx', '.js'] 18 | }, 19 | 20 | mode: 'production', 21 | devtool: false, // Remove source mapping. 'eval' is used by default in Webpack 5 22 | 23 | module: { 24 | rules: [ 25 | { 26 | test: /\.tsx?$/, 27 | use: 'ts-loader', 28 | exclude: /node_modules/, 29 | }, 30 | ], 31 | }, 32 | 33 | node: false, // Not include Node.js polyfills, https://webpack.js.org/configuration/node 34 | target: ['web', 'es5'], // target 'es5', since 'es2015' is the default in Webpack 5 35 | 36 | externals: { 37 | react: 'React' 38 | } 39 | }; 40 | -------------------------------------------------------------------------------- /src/SplitTreatments.tsx: -------------------------------------------------------------------------------- 1 | import * as React from 'react'; 2 | 3 | import { SplitContext } from './SplitContext'; 4 | import { ISplitTreatmentsProps } from './types'; 5 | import { useSplitTreatments } from './useSplitTreatments'; 6 | 7 | /** 8 | * SplitTreatments accepts a list of feature flag names and optional attributes. It accesses the client at SplitContext to 9 | * call the 'client.getTreatmentsWithConfig()' method if the `names` prop is provided, or the 'client.getTreatmentsWithConfigByFlagSets()' method 10 | * if the `flagSets` prop is provided. It then passes the resulting treatments to a child component as a function. 11 | * 12 | * @deprecated `SplitTreatments` will be removed in a future major release. We recommend replacing it with the `useTreatment*` hooks. 13 | */ 14 | export function SplitTreatments(props: ISplitTreatmentsProps) { 15 | const { children } = props; 16 | const context = useSplitTreatments(props); 17 | 18 | return ( 19 | 20 | { 21 | children(context) 22 | } 23 | 24 | ); 25 | } 26 | -------------------------------------------------------------------------------- /src/__tests__/SplitContext.test.tsx: -------------------------------------------------------------------------------- 1 | import * as React from 'react'; 2 | import { render } from '@testing-library/react'; 3 | import { SplitContext } from '../SplitContext'; 4 | import { SplitFactoryProvider } from '../SplitFactoryProvider'; 5 | import { INITIAL_STATUS } from './testUtils/utils'; 6 | 7 | /** 8 | * Test default SplitContext value 9 | */ 10 | test('SplitContext.Consumer shows default value', () => { 11 | render( 12 | 13 | {(value) => { 14 | expect(value).toBe(undefined); 15 | return null; 16 | }} 17 | 18 | ); 19 | }); 20 | 21 | test('SplitContext.Consumer shows value when wrapped in a SplitFactoryProvider', () => { 22 | render( 23 | 24 | 25 | {(value) => { 26 | expect(value).toEqual({ 27 | ...INITIAL_STATUS, 28 | factory: undefined, 29 | client: undefined 30 | }); 31 | return null; 32 | }} 33 | 34 | 35 | ); 36 | }); 37 | -------------------------------------------------------------------------------- /src/useTrack.ts: -------------------------------------------------------------------------------- 1 | import { useSplitClient } from './useSplitClient'; 2 | 3 | // no-op function that returns false 4 | const noOpFalse = () => false; 5 | 6 | /** 7 | * `useTrack` is a hook that retrieves the track method from a Split client. 8 | * 9 | * @returns The track method of the Split client for the provided user key. If the client is not available, the result is a no-op function that returns false. 10 | * 11 | * @see {@link https://developer.harness.io/docs/feature-management-experimentation/sdks-and-infrastructure/client-side-sdks/javascript-sdk/#track} 12 | */ 13 | export function useTrack(splitKey?: SplitIO.SplitKey): SplitIO.IBrowserClient['track'] { 14 | // All update options are false to avoid re-renders. The track method doesn't need the client to be operational. 15 | const { client } = useSplitClient({ splitKey, updateOnSdkReady: false, updateOnSdkReadyFromCache: false, updateOnSdkTimedout: false, updateOnSdkUpdate: false }); 16 | 17 | // Retrieve the client `track` rather than a bound version of it, as there is no need to bind the function, and can be used as a reactive dependency that only changes if the underlying client changes. 18 | return client ? client.track : noOpFalse; 19 | } 20 | -------------------------------------------------------------------------------- /src/useSplitManager.ts: -------------------------------------------------------------------------------- 1 | import { useSplitClient } from './useSplitClient'; 2 | import { IUseSplitManagerResult } from './types'; 3 | import { useSplitContext } from './SplitContext'; 4 | 5 | /** 6 | * `useSplitManager` is a hook that returns an Split Context object with the manager instance from the Split factory. 7 | * 8 | * @returns A Split Context object merged with the manager and its status. 9 | * 10 | * @example 11 | * ```js 12 | * const { manager, isReady, isReadyFromCache, lastUpdate, ... } = useSplitManager(); 13 | * ``` 14 | * 15 | * @see {@link https://developer.harness.io/docs/feature-management-experimentation/sdks-and-infrastructure/client-side-sdks/javascript-sdk/#manager} 16 | */ 17 | export function useSplitManager(): IUseSplitManagerResult { 18 | // @TODO refactor next lines to `const context = useSplitClient();` when `SplitClient` is removed 19 | // This is required to avoid retrieving the status of a non-default client if context was updated by a `SplitClient` component. 20 | const { factory } = useSplitContext(); 21 | const context = useSplitClient({ splitKey: factory?.settings.core.key }); 22 | 23 | const manager = factory ? factory.manager() : undefined; 24 | 25 | return { 26 | ...context, 27 | manager, 28 | }; 29 | } 30 | -------------------------------------------------------------------------------- /.github/workflows/update-license-year.yml: -------------------------------------------------------------------------------- 1 | name: Update License Year 2 | 3 | on: 4 | schedule: 5 | - cron: "0 3 1 1 *" # 03:00 AM on January 1 6 | 7 | permissions: 8 | contents: write 9 | pull-requests: write 10 | 11 | jobs: 12 | test: 13 | runs-on: ubuntu-latest 14 | steps: 15 | - name: Checkout 16 | uses: actions/checkout@v5 17 | with: 18 | fetch-depth: 0 19 | 20 | - name: Set Current year 21 | run: "echo CURRENT=$(date +%Y) >> $GITHUB_ENV" 22 | 23 | - name: Set Previous Year 24 | run: "echo PREVIOUS=$(($CURRENT-1)) >> $GITHUB_ENV" 25 | 26 | - name: Update LICENSE 27 | uses: jacobtomlinson/gha-find-replace@v3 28 | with: 29 | find: ${{ env.PREVIOUS }} 30 | replace: ${{ env.CURRENT }} 31 | include: "LICENSE" 32 | regex: false 33 | 34 | - name: Commit files 35 | run: | 36 | git config user.name 'github-actions[bot]' 37 | git config user.email 'github-actions[bot]@users.noreply.github.com' 38 | git commit -m "Updated License Year" -a 39 | 40 | - name: Create Pull Request 41 | uses: peter-evans/create-pull-request@v5 42 | with: 43 | token: ${{ secrets.GITHUB_TOKEN }} 44 | title: Update License Year 45 | branch: update-license 46 | -------------------------------------------------------------------------------- /src/useSplitTreatments.ts: -------------------------------------------------------------------------------- 1 | import { ISplitTreatmentsChildProps, IUseSplitTreatmentsOptions } from './types'; 2 | import { useTreatmentsWithConfig } from '.'; 3 | 4 | /** 5 | * `useSplitTreatments` is a hook that returns an Split Context object extended with a `treatments` property object that contains feature flag evaluations. 6 | * It uses the `useSplitClient` hook to access the client, and invokes the `client.getTreatmentsWithConfig()` method if the `names` option is provided, 7 | * or the `client.getTreatmentsWithConfigByFlagSets()` method if the `flagSets` option is provided. 8 | * 9 | * @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. 10 | * @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. 11 | * 12 | * @example 13 | * ```js 14 | * const { treatments: { feature_1, feature_2 }, isReady, isReadyFromCache, hasTimedout, lastUpdate, ... } = useSplitTreatments({ names: ['feature_1', 'feature_2']}); 15 | * ``` 16 | * 17 | * @see {@link https://developer.harness.io/docs/feature-management-experimentation/sdks-and-infrastructure/client-side-sdks/javascript-sdk/#get-treatments-with-configurations} 18 | * 19 | * @deprecated `useSplitTreatments` will be removed in a future major release. We recommend replacing it with the `useTreatment*` hooks. 20 | */ 21 | export function useSplitTreatments(options: IUseSplitTreatmentsOptions): ISplitTreatmentsChildProps { 22 | return useTreatmentsWithConfig(options); 23 | } 24 | -------------------------------------------------------------------------------- /src/withSplitClient.tsx: -------------------------------------------------------------------------------- 1 | import * as React from 'react'; 2 | import { ISplitClientChildProps } from './types'; 3 | import { SplitClient } from './SplitClient'; 4 | 5 | /** 6 | * High-Order Component for SplitClient. 7 | * The wrapped component receives all the props of the container, 8 | * along with the passed props from SplitClient (see ISplitClientChildProps). 9 | * 10 | * @param splitKey - The customer identifier. 11 | * @param attributes - An object of type Attributes used to evaluate the feature flags. 12 | * 13 | * @deprecated `withSplitClient` will be removed in a future major release. We recommend replacing it with the `useSplitClient` hook. 14 | */ 15 | export function withSplitClient(splitKey: SplitIO.SplitKey, attributes?: SplitIO.Attributes) { 16 | 17 | return function withSplitClientHoc( 18 | WrappedComponent: React.ComponentType, 19 | updateOnSdkUpdate?: boolean, 20 | updateOnSdkTimedout?: boolean, 21 | updateOnSdkReady?: boolean, 22 | updateOnSdkReadyFromCache?: boolean, 23 | ) { 24 | 25 | return function wrapper(props: OuterProps) { 26 | return ( 27 | 34 | {(splitProps) => { 35 | return ( 36 | 38 | ); 39 | }} 40 | 41 | ); 42 | }; 43 | }; 44 | } 45 | -------------------------------------------------------------------------------- /src/index.ts: -------------------------------------------------------------------------------- 1 | // Split SDK factory (Renamed to avoid name conflict with SplitFactory component) 2 | export { SplitFactory } from '@splitsoftware/splitio/client'; 3 | 4 | // HOC functions 5 | export { withSplitFactory } from './withSplitFactory'; 6 | export { withSplitClient } from './withSplitClient'; 7 | export { withSplitTreatments } from './withSplitTreatments'; 8 | 9 | // Components 10 | export { SplitTreatments } from './SplitTreatments'; 11 | export { SplitClient } from './SplitClient'; 12 | export { SplitFactoryProvider } from './SplitFactoryProvider'; 13 | 14 | // Hooks 15 | export { useTrack } from './useTrack'; 16 | export { useTreatment } from './useTreatment'; 17 | export { useTreatments } from './useTreatments'; 18 | export { useTreatmentWithConfig } from './useTreatmentWithConfig'; 19 | export { useTreatmentsWithConfig } from './useTreatmentsWithConfig'; 20 | export { useSplitClient } from './useSplitClient'; 21 | export { useSplitTreatments } from './useSplitTreatments'; 22 | export { useSplitManager } from './useSplitManager'; 23 | 24 | // SplitContext 25 | export { SplitContext } from './SplitContext'; 26 | 27 | // Types 28 | export type { 29 | GetTreatmentsOptions, 30 | ISplitClientChildProps, 31 | ISplitClientProps, 32 | ISplitContextValues, 33 | ISplitFactoryChildProps, 34 | ISplitFactoryProviderProps, 35 | ISplitStatus, 36 | ISplitTreatmentsChildProps, 37 | ISplitTreatmentsProps, 38 | IUpdateProps, 39 | IUseSplitClientOptions, 40 | IUseSplitTreatmentsOptions, 41 | IUseSplitManagerResult, 42 | IUseTreatmentOptions, 43 | IUseTreatmentsOptions, 44 | IUseTreatmentResult, 45 | IUseTreatmentWithConfigResult, 46 | IUseTreatmentsResult, 47 | IUseTreatmentsWithConfigResult 48 | } from './types'; 49 | -------------------------------------------------------------------------------- /src/withSplitTreatments.tsx: -------------------------------------------------------------------------------- 1 | import * as React from 'react'; 2 | import { ISplitTreatmentsChildProps } from './types'; 3 | import { SplitTreatments } from './SplitTreatments'; 4 | 5 | /** 6 | * High-Order Component for SplitTreatments. 7 | * The wrapped component receives all the props of the container, 8 | * along with the passed props from SplitTreatments (see ISplitTreatmentsChildProps). 9 | * 10 | * @param names - list of feature flag names 11 | * @param attributes - An object of type Attributes used to evaluate the feature flags. 12 | * 13 | * @deprecated `withSplitTreatments` will be removed in a future major release. We recommend replacing it with the `useTreatment*` hooks. 14 | */ 15 | export function withSplitTreatments(names: string[], attributes?: SplitIO.Attributes) { 16 | 17 | return function withSplitTreatmentsHoc( 18 | WrappedComponent: React.ComponentType, 19 | updateOnSdkUpdate?: boolean, 20 | updateOnSdkTimedout?: boolean, 21 | updateOnSdkReady?: boolean, 22 | updateOnSdkReadyFromCache?: boolean, 23 | ) { 24 | 25 | return function wrapper(props: OuterProps) { 26 | return ( 27 | 34 | {(splitProps) => { 35 | return ( 36 | 38 | ); 39 | }} 40 | 41 | ); 42 | }; 43 | }; 44 | } 45 | -------------------------------------------------------------------------------- /tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | /* Basic Options */ 4 | "target": "es5", /* Specify ECMAScript target version: 'ES3' (default), 'ES5', 'ES2015', 'ES2016', 'ES2017', 'ES2018', 'ES2019' or 'ESNEXT'. */ 5 | // Using es6 module for tree-shaking tslib in UMD build (https://github.com/microsoft/TypeScript/issues/16999) 6 | "module": "es6", /* Specify module code generation: 'none', 'commonjs', 'amd', 'system', 'umd', 'es2015', or 'ESNext'. */ 7 | "jsx": "react", /* Specify JSX code generation: 'preserve', 'react-native', or 'react'. */ 8 | "declaration": false, /* Generates corresponding '.d.ts' file. */ 9 | "sourceMap": false, /* Generates corresponding '.map' file. */ 10 | "outDir": "./esm", /* Redirect output structure to the directory. */ 11 | "resolveJsonModule": true, /* Include modules imported with .json extension. */ 12 | "importHelpers": true, /* Import emit helpers from 'tslib'. */ 13 | 14 | /* Strict Type-Checking Options */ 15 | "strict": true, /* Enable all strict type-checking options. */ 16 | 17 | /* Module Resolution Options */ 18 | "moduleResolution": "node", /* Specify module resolution strategy: 'node' (Node.js) or 'classic' (TypeScript pre-1.6). */ 19 | "esModuleInterop": true /* Enables emit interoperability between CommonJS and ES Modules via creation of namespace objects for all imports. Implies 'allowSyntheticDefaultImports'. */ 20 | }, 21 | "include": [ 22 | "src" 23 | ], 24 | "exclude": [ 25 | "./src/__tests__", 26 | "umd.ts", 27 | ] 28 | } 29 | -------------------------------------------------------------------------------- /src/withSplitFactory.tsx: -------------------------------------------------------------------------------- 1 | import * as React from 'react'; 2 | import { ISplitFactoryChildProps } from './types'; 3 | import { SplitFactoryProvider } from './SplitFactoryProvider'; 4 | import { SplitClient } from './SplitClient'; 5 | 6 | /** 7 | * High-Order Component for `SplitFactoryProvider`. 8 | * The wrapped component receives all the props of the container, 9 | * along with the passed props from the Split context (see `ISplitFactoryChildProps`). 10 | * 11 | * @param config - Config object used to instantiate a Split factory 12 | * @param factory - Split factory instance to use instead of creating a new one with the config object. 13 | * @param attributes - An object of type Attributes used to evaluate the feature flags. 14 | * 15 | * @deprecated `withSplitFactory` will be removed in a future major release. We recommend replacing it with the `SplitFactoryProvider` component. 16 | */ 17 | export function withSplitFactory(config?: SplitIO.IBrowserSettings, factory?: SplitIO.IBrowserSDK, attributes?: SplitIO.Attributes) { 18 | 19 | return function withSplitFactoryHoc( 20 | WrappedComponent: React.ComponentType, 21 | updateOnSdkUpdate?: boolean, 22 | updateOnSdkTimedout?: boolean, 23 | updateOnSdkReady?: boolean, 24 | updateOnSdkReadyFromCache?: boolean, 25 | ) { 26 | 27 | return function wrapper(props: OuterProps) { 28 | return ( 29 | 32 | 38 | 39 | {(splitProps) => { 40 | return ( 41 | 43 | ); 44 | }} 45 | 46 | 47 | ); 48 | }; 49 | }; 50 | } 51 | -------------------------------------------------------------------------------- /.eslintrc.js: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | 'env': { 3 | 'browser': true, 4 | 'es6': true, 5 | 'node': true 6 | }, 7 | 'extends': [ 8 | 'eslint:recommended', 9 | 'plugin:react/recommended', 10 | 'plugin:@typescript-eslint/recommended', 11 | 'plugin:react-hooks/recommended' 12 | ], 13 | 'parser': '@typescript-eslint/parser', 14 | 'parserOptions': { 15 | 'ecmaVersion': 2015, 16 | 'sourceType': 'module' 17 | }, 18 | 'plugins': [ 19 | 'react', 20 | '@typescript-eslint', 21 | 'eslint-plugin-tsdoc', 22 | 'import' 23 | ], 24 | 'rules': { 25 | 'indent': ['error', 2, { 'SwitchCase': 1 }], 26 | 'quotes': ['warn', 'single', 'avoid-escape'], 27 | 'linebreak-style': ['error', 'unix'], 28 | 'camelcase': ['error', { 'properties': 'never' }], 29 | 'no-use-before-define': ['error', 'nofunc'], 30 | 'eol-last': ['error', 'always'], 31 | 'keyword-spacing': 'error', 32 | 'no-trailing-spaces': 'error', 33 | 'space-before-function-paren': ['error', { 'named': 'never' }], 34 | 'react/display-name': 'off', 35 | '@typescript-eslint/no-empty-function': 'off', 36 | '@typescript-eslint/no-inferrable-types': 'off', 37 | '@typescript-eslint/no-explicit-any': 'off', 38 | '@typescript-eslint/no-non-null-assertion': 'off', 39 | '@typescript-eslint/no-unused-vars': ['warn', { 'argsIgnorePattern': '^_' }], 40 | 'prefer-const': ['error', { 41 | 'destructuring': 'all' 42 | }] 43 | }, 44 | 'settings': { 45 | 'react': { 46 | 'version': '16.3.0' // minimum supported version of React 47 | } 48 | }, 49 | 'overrides': [{ 50 | 'files': ['src/**/*.ts', 'src/**/*.tsx'], 51 | 'excludedFiles': ['src/**/__tests__/**'], 52 | 'extends': [ 53 | 'plugin:compat/recommended' 54 | ], 55 | 'settings': { 56 | 'polyfills': [ 57 | 'Promise' // required as a polyfill by the user 58 | ] 59 | }, 60 | 'rules': { 61 | 'no-restricted-syntax': ['error', 'ForOfStatement', 'ForInStatement'], 62 | 'compat/compat': ['error', 'defaults, ie 11'], 63 | 'no-throw-literal': 'error', 64 | 'import/no-self-import': 'error', 65 | 'import/no-default-export': 'error', 66 | } 67 | }, { 68 | // Enable TSDoc rules for TypeScript files, allowing the use of JSDoc in JS files. 69 | 'files': ['**/*.ts', '**/*.tsx'], 70 | 'rules': { 71 | 'tsdoc/syntax': 'warn' 72 | } 73 | }] 74 | } 75 | -------------------------------------------------------------------------------- /CONTRIBUTORS-GUIDE.md: -------------------------------------------------------------------------------- 1 | # Contributing to the Split React SDK 2 | 3 | Split SDK is an open source project and we welcome feedback and contribution. The information below describes how to build the project with your changes, run the tests, and send the Pull Request(PR). 4 | 5 | ## Development 6 | 7 | ### Development process 8 | 9 | 1. Fork the repository and create a topic branch from `development` branch. Please use a descriptive name for your branch. 10 | 2. While developing, use descriptive messages in your commits. Avoid short or meaningless sentences like: "fix bug". 11 | 3. Make sure to add tests for both positive and negative cases. 12 | 4. Run the linter script of the project and fix any issues you find. 13 | 5. Run the build script and make sure it runs with no errors. 14 | 6. Run all tests and make sure there are no failures. 15 | 7. `git push` your changes to GitHub within your topic branch. 16 | 8. Open a Pull Request(PR) from your forked repo and into the `development` branch of the original repository. 17 | 9. When creating your PR, please fill out all the fields of the PR template, as applicable, for the project. 18 | 10. Check for conflicts once the pull request is created to make sure your PR can be merged cleanly into `development`. 19 | 11. Keep an eye out for any feedback or comments from Split's SDK team. 20 | 21 | ### Building the SDK 22 | 23 | For widespread use of the SDK with different environments and module formats, we have three different builds: 24 | * A bundled **UMD** file. 25 | * A **ES2015** modules compatible build. 26 | * A **CommonJS** modules compatible build. 27 | 28 | The different builds can be generated all at once with the command `npm run build`. Refer to [package.json](package.json) for more insight on the build scripts. 29 | 30 | ### Running tests 31 | 32 | All tests can be run at once with the command `npm run test`. 33 | 34 | For additional testing scripts, refer to our [package.json](package.json) file. 35 | 36 | ### Linting and other useful checks 37 | 38 | Consider running the linter script (`npm run check`) and fixing any issues before pushing your changes. 39 | 40 | If you want to debug your changes consuming it from a test application, you can use our [app example](./example/react-spa/README.md). You could use symlinks via [npm link command](https://docs.npmjs.com/cli/link.html) and then import the package. 41 | 42 | # Contact 43 | 44 | If you have any other questions or need to contact us directly in a private manner send us a note at sdks@split.io 45 | -------------------------------------------------------------------------------- /src/useTreatment.ts: -------------------------------------------------------------------------------- 1 | import * as React from 'react'; 2 | import memoizeOne from 'memoize-one'; 3 | import { argsAreEqual, getTreatment } from './utils'; 4 | import { IUseTreatmentResult, IUseTreatmentOptions } from './types'; 5 | import { useSplitClient } from './useSplitClient'; 6 | 7 | function evaluateFeatureFlag(client: SplitIO.IBrowserClient | undefined, _lastUpdate: number, names: string[], attributes?: SplitIO.Attributes, _clientAttributes?: SplitIO.Attributes, _flagSets?: undefined, options?: SplitIO.EvaluationOptions, factory?: SplitIO.IBrowserSDK) { 8 | return client && client.getStatus().isOperational ? 9 | client.getTreatment(names[0], attributes, options) : 10 | getTreatment(names[0], false, factory); 11 | } 12 | 13 | function memoizeGetTreatment() { 14 | return memoizeOne(evaluateFeatureFlag, argsAreEqual); 15 | } 16 | 17 | /** 18 | * `useTreatment` is a hook that returns an Split Context object extended with a `treatment` property. 19 | * It uses the `useSplitClient` hook to access the client, and invokes the `client.getTreatment()` method. 20 | * 21 | * @param options - An options object with a feature flag name to evaluate, and an optional `attributes` and `splitKey` values to configure the client. 22 | * @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. 23 | * 24 | * @example 25 | * ```js 26 | * const { treatment, isReady, isReadyFromCache, hasTimedout, lastUpdate, ... } = useTreatment({ name: 'feature_1'}); 27 | * ``` 28 | * 29 | * @see {@link https://developer.harness.io/docs/feature-management-experimentation/sdks-and-infrastructure/client-side-sdks/javascript-sdk/#multiple-evaluations-at-once} 30 | */ 31 | export function useTreatment(options: IUseTreatmentOptions): IUseTreatmentResult { 32 | const context = useSplitClient({ ...options, attributes: undefined }); 33 | const { factory, client, lastUpdate } = context; 34 | const { name, attributes, properties } = options; 35 | 36 | const getTreatment = React.useMemo(memoizeGetTreatment, []); 37 | 38 | // Shallow copy `client.getAttributes` result for memoization, as it returns the same reference unless `client.clearAttributes` is invoked. 39 | // 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. 40 | const treatment = getTreatment(client, lastUpdate, [name], attributes, client ? { ...client.getAttributes() } : {}, undefined, properties && { properties }, factory); 41 | 42 | return { 43 | ...context, 44 | treatment, 45 | }; 46 | } 47 | -------------------------------------------------------------------------------- /src/useTreatmentWithConfig.ts: -------------------------------------------------------------------------------- 1 | import * as React from 'react'; 2 | import memoizeOne from 'memoize-one'; 3 | import { argsAreEqual, getTreatment } from './utils'; 4 | import { IUseTreatmentWithConfigResult, IUseTreatmentOptions } from './types'; 5 | import { useSplitClient } from './useSplitClient'; 6 | 7 | function evaluateFeatureFlagWithConfig(client: SplitIO.IBrowserClient | undefined, _lastUpdate: number, names: string[], attributes?: SplitIO.Attributes, _clientAttributes?: SplitIO.Attributes, _flagSets?: undefined, options?: SplitIO.EvaluationOptions, factory?: SplitIO.IBrowserSDK) { 8 | return client && client.getStatus().isOperational ? 9 | client.getTreatmentWithConfig(names[0], attributes, options) : 10 | getTreatment(names[0], true, factory); 11 | } 12 | 13 | function memoizeGetTreatmentWithConfig() { 14 | return memoizeOne(evaluateFeatureFlagWithConfig, argsAreEqual); 15 | } 16 | 17 | /** 18 | * `useTreatmentWithConfig` is a hook that returns an Split Context object extended with a `treatment` property. 19 | * It uses the `useSplitClient` hook to access the client, and invokes the `client.getTreatmentWithConfig()` method. 20 | * 21 | * @param options - An options object with a feature flag name to evaluate, and an optional `attributes` and `splitKey` values to configure the client. 22 | * @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. 23 | * 24 | * @example 25 | * ```js 26 | * const { treatment: { treatment, config }, isReady, isReadyFromCache, hasTimedout, lastUpdate, ... } = useTreatmentWithConfig({ name: 'feature_1'}); 27 | * ``` 28 | * 29 | * @see {@link https://developer.harness.io/docs/feature-management-experimentation/sdks-and-infrastructure/client-side-sdks/javascript-sdk/#get-treatments-with-configurations} 30 | */ 31 | export function useTreatmentWithConfig(options: IUseTreatmentOptions): IUseTreatmentWithConfigResult { 32 | const context = useSplitClient({ ...options, attributes: undefined }); 33 | const { factory, client, lastUpdate } = context; 34 | const { name, attributes, properties } = options; 35 | 36 | const getTreatmentWithConfig = React.useMemo(memoizeGetTreatmentWithConfig, []); 37 | 38 | // Shallow copy `client.getAttributes` result for memoization, as it returns the same reference unless `client.clearAttributes` is invoked. 39 | // 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. 40 | const treatment = getTreatmentWithConfig(client, lastUpdate, [name], attributes, client ? { ...client.getAttributes() } : {}, undefined, properties && { properties }, factory); 41 | 42 | return { 43 | ...context, 44 | treatment, 45 | }; 46 | } 47 | -------------------------------------------------------------------------------- /src/__tests__/withSplitTreatments.test.tsx: -------------------------------------------------------------------------------- 1 | import * as React from 'react'; 2 | import { act, render } from '@testing-library/react'; 3 | 4 | /** Mocks */ 5 | import { mockSdk, getLastInstance, Event } from './testUtils/mockSplitFactory'; 6 | jest.mock('@splitsoftware/splitio/client', () => { 7 | return { SplitFactory: mockSdk() }; 8 | }); 9 | import { SplitFactory } from '@splitsoftware/splitio/client'; 10 | import { sdkBrowser } from './testUtils/sdkConfigs'; 11 | import { INITIAL_STATUS } from './testUtils/utils'; 12 | 13 | /** Test target */ 14 | import { withSplitFactory } from '../withSplitFactory'; 15 | import { withSplitClient } from '../withSplitClient'; 16 | import { withSplitTreatments } from '../withSplitTreatments'; 17 | import { getTreatments } from '../utils'; 18 | 19 | const featureFlagNames = ['split1', 'split2']; 20 | 21 | describe('withSplitTreatments', () => { 22 | 23 | it(`passes Split props and outer props to the child. 24 | In this test, the value of "props.treatments" is obtained by the function "getTreatments", 25 | and not "client.getTreatmentsWithConfig" since the client is not ready.`, () => { 26 | 27 | const Component = withSplitFactory(sdkBrowser)<{ outerProp1: string, outerProp2: number }>( 28 | ({ outerProp1, outerProp2, factory }) => { 29 | const SubComponent = withSplitClient('user1')<{ outerProp1: string, outerProp2: number }>( 30 | withSplitTreatments(featureFlagNames)( 31 | (props) => { 32 | const clientMock = factory!.client('user1'); 33 | expect((clientMock.getTreatmentsWithConfig as jest.Mock).mock.calls.length).toBe(0); 34 | 35 | expect(props).toStrictEqual({ 36 | ...INITIAL_STATUS, 37 | factory: factory, client: clientMock, 38 | outerProp1: 'outerProp1', outerProp2: 2, 39 | treatments: getTreatments(featureFlagNames, true), 40 | }); 41 | 42 | return null; 43 | } 44 | ) 45 | ); 46 | return ; 47 | }); 48 | 49 | render(); 50 | }); 51 | 52 | it('disabling "updateOnSdkTimedout" requires passing `false` in all HOCs since the default value is `true`.', () => { 53 | 54 | let renderCount = 0; 55 | 56 | const Component = withSplitFactory(sdkBrowser)( 57 | withSplitClient(sdkBrowser.core.key)( 58 | withSplitTreatments(featureFlagNames)( 59 | (props) => { 60 | renderCount++; 61 | expect(props.hasTimedout).toBe(false); 62 | 63 | return null; 64 | }, undefined, false 65 | ), undefined, false 66 | ), undefined, false 67 | ); 68 | 69 | render(); 70 | 71 | act(() => getLastInstance(SplitFactory).client().__emitter__.emit(Event.SDK_READY_TIMED_OUT)); 72 | 73 | expect(renderCount).toBe(1); 74 | }); 75 | 76 | }); 77 | -------------------------------------------------------------------------------- /src/__tests__/utils.test.ts: -------------------------------------------------------------------------------- 1 | import { CONTROL, CONTROL_WITH_CONFIG } from '../constants'; 2 | import { getTreatments, getTreatment } from '../utils'; 3 | import { sdkBrowserWithConfig } from './testUtils/sdkConfigs'; 4 | 5 | const factoryWithoutFallbacks = { 6 | settings: {} 7 | } as SplitIO.IBrowserSDK; 8 | 9 | const factoryWithFallbacks = { 10 | settings: sdkBrowserWithConfig 11 | } as SplitIO.IBrowserSDK 12 | 13 | describe('getTreatments', () => { 14 | 15 | it('should return an empty object if an empty array is provided', () => { 16 | expect(getTreatments([], true)).toEqual({}); 17 | expect(getTreatments([], false)).toEqual({}); 18 | }); 19 | 20 | it('should return an object with control treatments if an array of feature flag names is provided', () => { 21 | const featureFlagNames = ['split1', 'split2']; 22 | const treatmentsWithConfig: SplitIO.TreatmentsWithConfig = getTreatments(featureFlagNames, true); 23 | expect(treatmentsWithConfig).toEqual({ 'split1': CONTROL_WITH_CONFIG, 'split2': CONTROL_WITH_CONFIG }); 24 | 25 | const treatments: SplitIO.Treatments = getTreatments(featureFlagNames, false); 26 | expect(treatments).toEqual({ 'split1': CONTROL, 'split2': CONTROL }); 27 | 28 | expect(getTreatments(featureFlagNames, true, factoryWithoutFallbacks)).toEqual({ 'split1': CONTROL_WITH_CONFIG, 'split2': CONTROL_WITH_CONFIG }); 29 | expect(getTreatments(featureFlagNames, false, factoryWithoutFallbacks)).toEqual({ 'split1': CONTROL, 'split2': CONTROL }); 30 | }); 31 | 32 | it('should return an object with fallback or control treatments if an array of feature flag names and factory are provided', () => { 33 | const featureFlagNames = ['split1', 'ff1']; 34 | const treatmentsWithConfig: SplitIO.TreatmentsWithConfig = getTreatments(featureFlagNames, true, factoryWithFallbacks); 35 | expect(treatmentsWithConfig).toEqual({ 'split1': { treatment: 'control_global', config: null }, 'ff1': { treatment: 'control_ff1', config: 'control_ff1_config' } }); 36 | 37 | const treatments: SplitIO.Treatments = getTreatments(featureFlagNames, false, factoryWithFallbacks); 38 | expect(treatments).toEqual({ 'split1': 'control_global', 'ff1': 'control_ff1' }); 39 | }); 40 | 41 | }); 42 | 43 | describe('getTreatment', () => { 44 | 45 | it('should return control treatments', () => { 46 | expect(getTreatment('any', true)).toEqual(CONTROL_WITH_CONFIG); 47 | expect(getTreatment('any', false)).toEqual(CONTROL); 48 | 49 | expect(getTreatment('any', true, factoryWithoutFallbacks)).toEqual(CONTROL_WITH_CONFIG); 50 | expect(getTreatment('any', false, factoryWithoutFallbacks)).toEqual(CONTROL); 51 | }); 52 | 53 | it('should return fallback treatments if a factory with fallback treatments is provided', () => { 54 | const treatmentWithConfig: SplitIO.TreatmentWithConfig = getTreatment('split1', true, factoryWithFallbacks); 55 | expect(treatmentWithConfig).toEqual({ treatment: 'control_global', config: null }); 56 | 57 | const treatment: SplitIO.Treatment = getTreatment('ff1', false, factoryWithFallbacks); 58 | expect(treatment).toEqual('control_ff1' ); 59 | }); 60 | 61 | }); 62 | -------------------------------------------------------------------------------- /src/useTreatments.ts: -------------------------------------------------------------------------------- 1 | import * as React from 'react'; 2 | import memoizeOne from 'memoize-one'; 3 | import { argsAreEqual, getTreatments } from './utils'; 4 | import { IUseTreatmentsResult, IUseTreatmentsOptions } from './types'; 5 | import { useSplitClient } from './useSplitClient'; 6 | 7 | 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) { 8 | return client && client.getStatus().isOperational && (names || flagSets) ? 9 | names ? 10 | client.getTreatments(names, attributes, options) : 11 | client.getTreatmentsByFlagSets(flagSets!, attributes, options) : 12 | names ? 13 | getTreatments(names, false, factory) : 14 | {} // empty object when evaluating with flag sets and client is not ready 15 | } 16 | 17 | export function memoizeGetTreatments() { 18 | return memoizeOne(evaluateFeatureFlags, argsAreEqual); 19 | } 20 | 21 | /** 22 | * `useTreatments` is a hook that returns an Split Context object extended with a `treatments` property object that contains feature flag evaluations. 23 | * It uses the `useSplitClient` hook to access the client, and invokes the `client.getTreatments()` method if the `names` option is provided, 24 | * or the `client.getTreatmentsByFlagSets()` method if the `flagSets` option is provided. 25 | * 26 | * @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. 27 | * @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. 28 | * 29 | * @example 30 | * ```js 31 | * const { treatments: { feature_1, feature_2 }, isReady, isReadyFromCache, hasTimedout, lastUpdate, ... } = useTreatments({ names: ['feature_1', 'feature_2']}); 32 | * ``` 33 | * 34 | * @see {@link https://developer.harness.io/docs/feature-management-experimentation/sdks-and-infrastructure/client-side-sdks/javascript-sdk/#multiple-evaluations-at-once} 35 | */ 36 | export function useTreatments(options: IUseTreatmentsOptions): IUseTreatmentsResult { 37 | const context = useSplitClient({ ...options, attributes: undefined }); 38 | const { factory, client, lastUpdate } = context; 39 | const { names, flagSets, attributes, properties } = options; 40 | 41 | const getTreatments = React.useMemo(memoizeGetTreatments, []); 42 | 43 | // Shallow copy `client.getAttributes` result for memoization, as it returns the same reference unless `client.clearAttributes` is invoked. 44 | // 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. 45 | const treatments = getTreatments(client, lastUpdate, names, attributes, client ? { ...client.getAttributes() } : {}, flagSets, properties && { properties }, factory); 46 | 47 | return { 48 | ...context, 49 | treatments, 50 | }; 51 | } 52 | -------------------------------------------------------------------------------- /src/useTreatmentsWithConfig.ts: -------------------------------------------------------------------------------- 1 | import * as React from 'react'; 2 | import memoizeOne from 'memoize-one'; 3 | import { argsAreEqual, getTreatments } from './utils'; 4 | import { IUseTreatmentsOptions, IUseTreatmentsWithConfigResult } from './types'; 5 | import { useSplitClient } from './useSplitClient'; 6 | 7 | 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) { 8 | return client && client.getStatus().isOperational && (names || flagSets) ? 9 | names ? 10 | client.getTreatmentsWithConfig(names, attributes, options) : 11 | client.getTreatmentsWithConfigByFlagSets(flagSets!, attributes, options) : 12 | names ? 13 | getTreatments(names, true, factory) : 14 | {} // empty object when evaluating with flag sets and client is not ready 15 | } 16 | 17 | function memoizeGetTreatmentsWithConfig() { 18 | return memoizeOne(evaluateFeatureFlagsWithConfig, argsAreEqual); 19 | } 20 | 21 | /** 22 | * `useTreatmentsWithConfig` is a hook that returns an Split Context object extended with a `treatments` property object that contains feature flag evaluations. 23 | * It uses the `useSplitClient` hook to access the client, and invokes the `client.getTreatmentsWithConfig()` method if the `names` option is provided, 24 | * or the `client.getTreatmentsWithConfigByFlagSets()` method if the `flagSets` option is provided. 25 | * 26 | * @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. 27 | * @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. 28 | * 29 | * @example 30 | * ```js 31 | * const { treatments: { feature_1, feature_2 }, isReady, isReadyFromCache, hasTimedout, lastUpdate, ... } = useTreatmentsWithConfig({ names: ['feature_1', 'feature_2']}); 32 | * ``` 33 | * 34 | * @see {@link https://developer.harness.io/docs/feature-management-experimentation/sdks-and-infrastructure/client-side-sdks/javascript-sdk/#get-treatments-with-configurations} 35 | */ 36 | export function useTreatmentsWithConfig(options: IUseTreatmentsOptions): IUseTreatmentsWithConfigResult { 37 | const context = useSplitClient({ ...options, attributes: undefined }); 38 | const { factory, client, lastUpdate } = context; 39 | const { names, flagSets, attributes, properties } = options; 40 | 41 | const getTreatmentsWithConfig = React.useMemo(memoizeGetTreatmentsWithConfig, []); 42 | 43 | // Shallow copy `client.getAttributes` result for memoization, as it returns the same reference unless `client.clearAttributes` is invoked. 44 | // 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. 45 | const treatments = getTreatmentsWithConfig(client, lastUpdate, names, attributes, client ? { ...client.getAttributes() } : {}, flagSets, properties && { properties }, factory); 46 | 47 | return { 48 | ...context, 49 | treatments, 50 | }; 51 | } 52 | -------------------------------------------------------------------------------- /src/__tests__/index.test.ts: -------------------------------------------------------------------------------- 1 | /* eslint-disable @typescript-eslint/no-unused-vars */ 2 | import { 3 | SplitContext as ExportedSplitContext, 4 | SplitFactory as ExportedSplitFactory, 5 | SplitFactoryProvider as ExportedSplitFactoryProvider, 6 | SplitClient as ExportedSplitClient, 7 | SplitTreatments as ExportedSplitTreatments, 8 | withSplitFactory as exportedWithSplitFactory, 9 | withSplitClient as exportedWithSplitClient, 10 | withSplitTreatments as exportedWithSplitTreatments, 11 | useTrack as exportedUseTrack, 12 | useSplitClient as exportedUseSplitClient, 13 | useSplitTreatments as exportedUseSplitTreatments, 14 | useSplitManager as exportedUseSplitManager, 15 | useTreatment as exportedUseTreatment, 16 | useTreatmentWithConfig as exportedUseTreatmentWithConfig, 17 | useTreatments as exportedUseTreatments, 18 | useTreatmentsWithConfig as exportedUseTreatmentsWithConfig, 19 | // Checks that types are exported. Otherwise, the test would fail with a TS error. 20 | GetTreatmentsOptions, 21 | ISplitClientChildProps, 22 | ISplitClientProps, 23 | ISplitContextValues, 24 | ISplitFactoryChildProps, 25 | ISplitFactoryProviderProps, 26 | ISplitStatus, 27 | ISplitTreatmentsChildProps, 28 | ISplitTreatmentsProps, 29 | IUpdateProps, 30 | IUseSplitClientOptions, 31 | IUseSplitTreatmentsOptions, 32 | IUseSplitManagerResult 33 | } from '../index'; 34 | import { SplitContext } from '../SplitContext'; 35 | import { SplitFactory } from '@splitsoftware/splitio/client'; 36 | import { SplitFactoryProvider } from '../SplitFactoryProvider'; 37 | import { SplitClient } from '../SplitClient'; 38 | import { SplitTreatments } from '../SplitTreatments'; 39 | import { withSplitFactory } from '../withSplitFactory'; 40 | import { withSplitClient } from '../withSplitClient'; 41 | import { withSplitTreatments } from '../withSplitTreatments'; 42 | import { useTrack } from '../useTrack'; 43 | import { useSplitClient } from '../useSplitClient'; 44 | import { useSplitTreatments } from '../useSplitTreatments'; 45 | import { useSplitManager } from '../useSplitManager'; 46 | import { useTreatment } from '../useTreatment'; 47 | import { useTreatmentWithConfig } from '../useTreatmentWithConfig'; 48 | import { useTreatments } from '../useTreatments'; 49 | import { useTreatmentsWithConfig } from '../useTreatmentsWithConfig'; 50 | 51 | describe('index', () => { 52 | 53 | it('should export components', () => { 54 | expect(ExportedSplitFactoryProvider).toBe(SplitFactoryProvider); 55 | expect(ExportedSplitClient).toBe(SplitClient); 56 | expect(ExportedSplitTreatments).toBe(SplitTreatments); 57 | }); 58 | 59 | it('should export HOCs', () => { 60 | expect(exportedWithSplitFactory).toBe(withSplitFactory); 61 | expect(exportedWithSplitClient).toBe(withSplitClient); 62 | expect(exportedWithSplitTreatments).toBe(withSplitTreatments); 63 | }); 64 | 65 | it('should export hooks', () => { 66 | expect(exportedUseTrack).toBe(useTrack); 67 | expect(exportedUseSplitClient).toBe(useSplitClient); 68 | expect(exportedUseSplitTreatments).toBe(useSplitTreatments); 69 | expect(exportedUseSplitManager).toBe(useSplitManager); 70 | expect(exportedUseTreatment).toBe(useTreatment); 71 | expect(exportedUseTreatmentWithConfig).toBe(useTreatmentWithConfig); 72 | expect(exportedUseTreatments).toBe(useTreatments); 73 | expect(exportedUseTreatmentsWithConfig).toBe(useTreatmentsWithConfig); 74 | }); 75 | 76 | it('should export SplitContext', () => { 77 | expect(ExportedSplitContext).toBe(SplitContext); 78 | }); 79 | 80 | it('should export SplitFactory', () => { 81 | expect(ExportedSplitFactory).toBe(SplitFactory); 82 | }); 83 | 84 | }); 85 | -------------------------------------------------------------------------------- /src/__tests__/withSplitFactory.test.tsx: -------------------------------------------------------------------------------- 1 | import * as React from 'react'; 2 | import { render } from '@testing-library/react'; 3 | 4 | /** Mocks */ 5 | import { mockSdk, Event } from './testUtils/mockSplitFactory'; 6 | jest.mock('@splitsoftware/splitio/client', () => { 7 | return { SplitFactory: mockSdk() }; 8 | }); 9 | import { SplitFactory } from '@splitsoftware/splitio/client'; 10 | import { sdkBrowser } from './testUtils/sdkConfigs'; 11 | import { SplitClient } from '../SplitClient'; 12 | jest.mock('../SplitClient'); 13 | 14 | /** Test target */ 15 | import { ISplitFactoryChildProps } from '../types'; 16 | import { withSplitFactory } from '../withSplitFactory'; 17 | 18 | describe('withSplitFactory', () => { 19 | 20 | beforeEach(() => { 21 | jest.clearAllMocks(); 22 | }); 23 | 24 | test('passes no-ready props to the child if initialized with a no ready factory (e.g., using config object).', () => { 25 | const Component = withSplitFactory(sdkBrowser)( 26 | ({ factory, isReady, isReadyFromCache, hasTimedout, isTimedout, isDestroyed, lastUpdate }: ISplitFactoryChildProps) => { 27 | expect(factory).toBeInstanceOf(Object); 28 | expect([isReady, isReadyFromCache, hasTimedout, isTimedout, isDestroyed, lastUpdate]).toStrictEqual([false, false, false, false, false, 0]); 29 | return null; 30 | } 31 | ); 32 | render(); 33 | }); 34 | 35 | test('passes ready props to the child if initialized with a ready factory.', (done) => { 36 | const outerFactory = SplitFactory(sdkBrowser); 37 | (outerFactory as any).client().__emitter__.emit(Event.SDK_READY); 38 | (outerFactory.manager().names as jest.Mock).mockReturnValue(['split1']); 39 | outerFactory.client().ready().then(() => { 40 | const Component = withSplitFactory(undefined, outerFactory)( 41 | ({ factory, isReady, isReadyFromCache, hasTimedout, isTimedout, isDestroyed, lastUpdate }: ISplitFactoryChildProps) => { 42 | expect(factory).toBe(outerFactory); 43 | expect([isReady, isReadyFromCache, hasTimedout, isTimedout, isDestroyed, lastUpdate]).toStrictEqual([true, true, false, false, false, 0]); 44 | return null; 45 | } 46 | ); 47 | render(); 48 | done(); 49 | }); 50 | }); 51 | 52 | test('passes Split props and outer props to the child.', () => { 53 | const Component = withSplitFactory(sdkBrowser)<{ outerProp1: string, outerProp2: number }>( 54 | ({ outerProp1, outerProp2, factory, isReady, isReadyFromCache, hasTimedout, isTimedout, isDestroyed, lastUpdate }) => { 55 | expect(outerProp1).toBe('outerProp1'); 56 | expect(outerProp2).toBe(2); 57 | expect(factory).toBeInstanceOf(Object); 58 | expect([isReady, isReadyFromCache, hasTimedout, isTimedout, isDestroyed, lastUpdate]).toStrictEqual([false, false, false, false, false, 0]); 59 | return null; 60 | } 61 | ); 62 | render(); 63 | }); 64 | 65 | test('passes Status props to SplitFactory.', () => { 66 | const updateOnSdkUpdate = true; 67 | const updateOnSdkTimedout = false; 68 | const updateOnSdkReady = true; 69 | const updateOnSdkReadyFromCache = false; 70 | const Component = withSplitFactory(sdkBrowser)<{ outerProp1: string, outerProp2: number }>( 71 | () => null, updateOnSdkUpdate, updateOnSdkTimedout, updateOnSdkReady, updateOnSdkReadyFromCache 72 | ); 73 | 74 | render(); 75 | 76 | expect(SplitClient).toHaveBeenCalledTimes(1); 77 | expect((SplitClient as jest.Mock).mock.calls[0][0]).toMatchObject({ 78 | updateOnSdkUpdate, 79 | updateOnSdkTimedout, 80 | updateOnSdkReady, 81 | updateOnSdkReadyFromCache 82 | }); 83 | }); 84 | 85 | }); 86 | -------------------------------------------------------------------------------- /src/__tests__/useSplitManager.test.tsx: -------------------------------------------------------------------------------- 1 | import * as React from 'react'; 2 | import { act, render } from '@testing-library/react'; 3 | 4 | /** Mocks */ 5 | import { Event, mockSdk } from './testUtils/mockSplitFactory'; 6 | jest.mock('@splitsoftware/splitio/client', () => { 7 | return { SplitFactory: mockSdk() }; 8 | }); 9 | import { SplitFactory } from '@splitsoftware/splitio/client'; 10 | import { sdkBrowser } from './testUtils/sdkConfigs'; 11 | import { getStatus } from '../utils'; 12 | 13 | /** Test target */ 14 | import { SplitFactoryProvider } from '../SplitFactoryProvider'; 15 | import { SplitClient } from '../SplitClient'; 16 | import { useSplitManager } from '../useSplitManager'; 17 | import { EXCEPTION_NO_SFP } from '../constants'; 18 | import { INITIAL_STATUS } from './testUtils/utils'; 19 | 20 | describe('useSplitManager', () => { 21 | 22 | test('returns the factory manager and its status, and updates on SDK events.', () => { 23 | const outerFactory = SplitFactory(sdkBrowser); 24 | let hookResult; 25 | render( 26 | 27 | {React.createElement(() => { 28 | hookResult = useSplitManager(); 29 | return null; 30 | })} 31 | 32 | ); 33 | 34 | expect(hookResult).toStrictEqual({ 35 | ...INITIAL_STATUS, 36 | manager: outerFactory.manager(), 37 | client: outerFactory.client(), 38 | factory: outerFactory, 39 | }); 40 | 41 | act(() => (outerFactory.client() as any).__emitter__.emit(Event.SDK_READY)); 42 | 43 | expect(hookResult).toStrictEqual({ 44 | ...INITIAL_STATUS, 45 | manager: outerFactory.manager(), 46 | client: outerFactory.client(), 47 | factory: outerFactory, 48 | hasTimedout: false, 49 | isDestroyed: false, 50 | isReady: true, 51 | isReadyFromCache: true, 52 | isTimedout: false, 53 | isOperational: true, 54 | lastUpdate: getStatus(outerFactory.client()).lastUpdate, 55 | }); 56 | }); 57 | 58 | test('throws error if invoked outside of SplitFactoryProvider.', () => { 59 | expect(() => { 60 | render( 61 | React.createElement(() => { 62 | useSplitManager(); 63 | return null; 64 | }) 65 | ); 66 | }).toThrow(EXCEPTION_NO_SFP); 67 | }); 68 | 69 | // @TODO remove next test case when `SplitClient` is removed. 70 | test('returns the factory manager and its status, even if the Split context was updated by an SplitClient component', () => { 71 | const outerFactory = SplitFactory(sdkBrowser); 72 | let hookResult; 73 | render( 74 | 75 | 76 | {React.createElement(() => { 77 | hookResult = useSplitManager(); 78 | return null; 79 | })} 80 | 81 | 82 | ); 83 | 84 | expect(hookResult).toStrictEqual({ 85 | ...INITIAL_STATUS, 86 | manager: outerFactory.manager(), 87 | client: outerFactory.client(), 88 | factory: outerFactory, 89 | }); 90 | 91 | act(() => (outerFactory.client() as any).__emitter__.emit(Event.SDK_READY)); 92 | // act(() => (outerFactory.client() as any).__emitter__.emit(Event.SDK_READY)); 93 | 94 | expect(hookResult).toStrictEqual({ 95 | ...INITIAL_STATUS, 96 | manager: outerFactory.manager(), 97 | client: outerFactory.client(), 98 | factory: outerFactory, 99 | hasTimedout: false, 100 | isDestroyed: false, 101 | isReady: true, 102 | isReadyFromCache: true, 103 | isTimedout: false, 104 | isOperational: true, 105 | lastUpdate: getStatus(outerFactory.client()).lastUpdate, 106 | }); 107 | }); 108 | 109 | }); 110 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "@splitsoftware/splitio-react", 3 | "version": "2.6.1", 4 | "description": "A React library to easily integrate and use Split JS SDK", 5 | "main": "cjs/index.js", 6 | "module": "esm/index.js", 7 | "types": "types/index.d.ts", 8 | "files": [ 9 | "README.md", 10 | "CONTRIBUTORS-GUIDE.md", 11 | "MIGRATION-GUIDE.md", 12 | "LICENSE", 13 | "CHANGES.txt", 14 | "src", 15 | "cjs", 16 | "esm", 17 | "types" 18 | ], 19 | "sideEffects": false, 20 | "scripts": { 21 | "build:cjs": "rimraf cjs/* types/* && tsc -m commonjs --outDir cjs -d true --declarationDir types", 22 | "build:esm": "rimraf esm/* && tsc", 23 | "build:umd": "rimraf umd/* && webpack --config webpack.dev.js --env branch=$BUILD_BRANCH && webpack --config webpack.prod.js --env branch=$BUILD_BRANCH", 24 | "build": "npm run build:cjs && npm run build:esm && npm run build:umd", 25 | "postbuild": "replace 'REACT_SDK_VERSION_NUMBER' $npm_package_version ./cjs/constants.js ./esm/constants.js ./umd -r", 26 | "check": "npm run check:lint && npm run check:types", 27 | "check:lint": "eslint 'src/**/*.ts*'", 28 | "check:types": "tsc --noEmit", 29 | "test": "jest src --silent", 30 | "test:watch": "npm test -- --watch", 31 | "test:coverage": "jest src --coverage", 32 | "test:debug": "node --inspect node_modules/.bin/jest --runInBand", 33 | "all": "npm run check && npm run build && npm run test", 34 | "publish:rc": "npm publish --tag rc", 35 | "publish:stable": "npm publish", 36 | "prepublishOnly": "npm run check && npm run build" 37 | }, 38 | "repository": { 39 | "type": "git", 40 | "url": "git+https://github.com/splitio/react-client.git" 41 | }, 42 | "keywords": [ 43 | "splitio", 44 | "react", 45 | "sdk" 46 | ], 47 | "author": "Split Software", 48 | "contributors": [ 49 | { 50 | "name": "Emiliano Sanchez", 51 | "email": "emiliano.sanchez@split.io", 52 | "url": "https://github.com/EmilianoSanchez" 53 | }, 54 | { 55 | "name": "Nico Zelaya", 56 | "email": "nicolas.zelaya@split.io", 57 | "url": "https://github.com/NicoZelaya" 58 | } 59 | ], 60 | "license": "Apache-2.0", 61 | "bugs": { 62 | "url": "https://github.com/splitio/react-client/issues" 63 | }, 64 | "homepage": "https://github.com/splitio/react-client#readme", 65 | "dependencies": { 66 | "@splitsoftware/splitio": "11.9.0", 67 | "memoize-one": "^5.1.1", 68 | "shallowequal": "^1.1.0", 69 | "tslib": "^2.3.1" 70 | }, 71 | "devDependencies": { 72 | "@testing-library/jest-dom": "^5.16.5", 73 | "@testing-library/react": "^16.3.0", 74 | "@types/events": "^3.0.0", 75 | "@types/jest": "^27.0.0", 76 | "@types/react": "^19.0.0", 77 | "@types/react-dom": "^19.0.0", 78 | "@types/react-test-renderer": "^19.0.0", 79 | "@types/shallowequal": "^1.1.1", 80 | "@typescript-eslint/eslint-plugin": "^6.6.0", 81 | "@typescript-eslint/parser": "^6.6.0", 82 | "eslint": "^8.48.0", 83 | "eslint-plugin-compat": "^4.2.0", 84 | "eslint-plugin-import": "^2.27.5", 85 | "eslint-plugin-react": "^7.32.2", 86 | "eslint-plugin-react-hooks": "^4.6.0", 87 | "eslint-plugin-tsdoc": "^0.3.0", 88 | "husky": "^3.1.0", 89 | "jest": "^27.2.3", 90 | "react": "^19.0.0", 91 | "react-dom": "^19.0.0", 92 | "react-test-renderer": "^19.0.0", 93 | "replace": "^1.2.1", 94 | "rimraf": "^3.0.0", 95 | "ts-jest": "^27.0.5", 96 | "ts-loader": "^6.2.1", 97 | "typescript": "4.5.5", 98 | "webpack": "^5.53.0", 99 | "webpack-cli": "^4.8.0", 100 | "webpack-merge": "^5.8.0" 101 | }, 102 | "peerDependencies": { 103 | "react": ">=16.8.0" 104 | }, 105 | "husky": { 106 | "hooks": { 107 | "pre-commit": "npm run check && npm run build", 108 | "pre-push": "npm test && npm run build" 109 | } 110 | } 111 | } 112 | -------------------------------------------------------------------------------- /src/SplitFactoryProvider.tsx: -------------------------------------------------------------------------------- 1 | import * as React from 'react'; 2 | 3 | import { ISplitFactoryProviderProps } from './types'; 4 | import { VERSION, WARN_SF_CONFIG_AND_FACTORY } from './constants'; 5 | import { getSplitClient, getStatus, initAttributes } from './utils'; 6 | import { SplitContext } from './SplitContext'; 7 | import { SplitFactory } from '@splitsoftware/splitio/client'; 8 | 9 | /** 10 | * Implementation rationale: 11 | * - Follows React rules: pure components & hooks, with side effects managed in `useEffect`. 12 | * - The `factory` and `client` properties in the context are available from the initial render, rather than being set lazily in a `useEffect`, so that: 13 | * - Hooks retrieve the correct values from the start; for example, `useTrack` accesses the client's `track` method rather than a no-op function (related to https://github.com/splitio/react-client/issues/198). 14 | * - Hooks can support Suspense and Server components where `useEffect` is not called (related to https://github.com/splitio/react-client/issues/192). 15 | * - Re-renders are avoided for child components that do not depend on the factory being ready (e.g., tracking events, updating attributes, or managing consent). 16 | * - `SplitFactoryProvider` updates the context only when props change (`config` or `factory`) but not the state (e.g., client status), preventing unnecessary updates to child components and allowing them to control when to update independently. 17 | * - For these reasons, and to reduce component tree depth, `SplitFactoryProvider` no longer wraps the child component in a `SplitClient` component and thus does not accept a child as a function. 18 | */ 19 | 20 | /** 21 | * The SplitFactoryProvider is the top level component that provides the Split SDK factory to all child components via the Split Context. 22 | * It accepts either an SDK `factory` instance or a `config` object as props to initialize a new SDK factory. 23 | * 24 | * NOTE: Either pass a `factory` instance or a `config` object as props. If both props are passed, the `config` prop will be ignored. 25 | * Pass the same reference to the `config` or `factory` object rather than a new instance on each render, to avoid unnecessary props changes and SDK re-initializations. 26 | * 27 | * @see {@link https://developer.harness.io/docs/feature-management-experimentation/sdks-and-infrastructure/client-side-sdks/react-sdk/#2-instantiate-the-sdk-and-create-a-new-split-client} 28 | */ 29 | export function SplitFactoryProvider(props: ISplitFactoryProviderProps) { 30 | const { 31 | config, factory: propFactory, attributes, 32 | updateOnSdkReady = true, updateOnSdkReadyFromCache = true, updateOnSdkTimedout = true, updateOnSdkUpdate = true 33 | } = props; 34 | 35 | const factory = React.useMemo void }>(() => { 36 | return propFactory ? 37 | propFactory : 38 | config ? 39 | // @ts-expect-error. 2nd param is not part of type definitions. Used to overwrite the SDK version and enable lazy init 40 | SplitFactory(config, (modules) => { 41 | modules.settings.version = VERSION; 42 | modules.lazyInit = true; 43 | }) : 44 | undefined; 45 | }, [config, propFactory]); 46 | 47 | const client = factory ? getSplitClient(factory) : undefined; 48 | 49 | initAttributes(client, attributes); 50 | 51 | // Effect to initialize and destroy the factory when config is provided 52 | React.useEffect(() => { 53 | if (propFactory) { 54 | if (config) (propFactory.settings as any).log.warn(WARN_SF_CONFIG_AND_FACTORY); 55 | return; 56 | } 57 | 58 | if (factory) { 59 | factory.init && factory.init(); 60 | 61 | return () => { 62 | factory.destroy(); 63 | } 64 | } 65 | }, [config, propFactory, factory]); 66 | 67 | return ( 68 | 72 | {props.children} 73 | 74 | ); 75 | } 76 | -------------------------------------------------------------------------------- /src/useSplitClient.ts: -------------------------------------------------------------------------------- 1 | import * as React from 'react'; 2 | import { useSplitContext } from './SplitContext'; 3 | import { getSplitClient, initAttributes, getStatus } from './utils'; 4 | import { ISplitContextValues, IUseSplitClientOptions } from './types'; 5 | 6 | /** 7 | * `useSplitClient` is a hook that returns an Split Context object with the client and its status corresponding to the provided key. 8 | * 9 | * @param options - An options object with an optional `splitKey` to retrieve the client, optional `attributes` to configure the client, and update options to control on which SDK events the hook should update. 10 | * @returns A Split Context object merged with the client and its status. 11 | * 12 | * @example 13 | * ```js 14 | * const { factory, client, isReady, isReadyFromCache, hasTimedout, lastUpdate } = useSplitClient({ splitKey: 'user_id' }); 15 | * ``` 16 | * 17 | * @see {@link https://developer.harness.io/docs/feature-management-experimentation/sdks-and-infrastructure/client-side-sdks/javascript-sdk/#advanced-instantiate-multiple-sdk-clients} 18 | */ 19 | export function useSplitClient(options: IUseSplitClientOptions = {}): ISplitContextValues { 20 | const context = useSplitContext(); 21 | const { client: contextClient, factory } = context; 22 | const { 23 | splitKey, attributes, 24 | updateOnSdkReady = context.updateOnSdkReady, 25 | updateOnSdkReadyFromCache = context.updateOnSdkReadyFromCache, 26 | updateOnSdkTimedout = context.updateOnSdkTimedout, 27 | updateOnSdkUpdate = context.updateOnSdkUpdate 28 | } = options; 29 | 30 | // @TODO Move `getSplitClient` side effects and reduce the function cognitive complexity 31 | // @TODO Once `SplitClient` is removed, which updates the context, simplify next line as `const client = factory ? getSplitClient(factory, splitKey) : undefined;` 32 | const client = factory && splitKey ? getSplitClient(factory, splitKey) : contextClient; 33 | 34 | initAttributes(client, attributes); 35 | 36 | const [lastUpdate, setLastUpdate] = React.useState(0); 37 | // `getStatus` is not pure. Its result depends on `client` and `lastUpdate` 38 | // eslint-disable-next-line react-hooks/exhaustive-deps 39 | const status = React.useMemo(() => getStatus(client), [client, lastUpdate]); 40 | 41 | // Handle client events 42 | React.useEffect(() => { 43 | if (!client) return; 44 | 45 | const update = () => setLastUpdate(getStatus(client).lastUpdate); 46 | 47 | // Clients are created on the hook's call, so the status may have changed 48 | const statusOnEffect = getStatus(client); 49 | 50 | // Subscribe to SDK events 51 | if (updateOnSdkReady !== false) { 52 | if (!statusOnEffect.isReady) client.once(client.Event.SDK_READY, update); 53 | else if (!status.isReady) update(); 54 | } 55 | if (updateOnSdkReadyFromCache !== false) { 56 | if (!statusOnEffect.isReadyFromCache) client.once(client.Event.SDK_READY_FROM_CACHE, update); 57 | else if (!status.isReadyFromCache) update(); 58 | } 59 | if (updateOnSdkTimedout !== false) { 60 | if (!statusOnEffect.hasTimedout) { 61 | // Required to avoid error log for event already emitted 62 | if (!statusOnEffect.isReady) client.once(client.Event.SDK_READY_TIMED_OUT, update); 63 | } else { 64 | if (!status.hasTimedout) update(); 65 | } 66 | } 67 | if (updateOnSdkUpdate !== false) { 68 | client.on(client.Event.SDK_UPDATE, update); 69 | if (statusOnEffect.isReady && statusOnEffect.lastUpdate > status.lastUpdate) update(); 70 | } 71 | 72 | return () => { 73 | // Unsubscribe from events 74 | client.off(client.Event.SDK_READY, update); 75 | client.off(client.Event.SDK_READY_FROM_CACHE, update); 76 | client.off(client.Event.SDK_READY_TIMED_OUT, update); 77 | client.off(client.Event.SDK_UPDATE, update); 78 | } 79 | }, [client, updateOnSdkReady, updateOnSdkReadyFromCache, updateOnSdkTimedout, updateOnSdkUpdate, status]); 80 | 81 | return { 82 | factory, client, ...status, updateOnSdkReady, updateOnSdkReadyFromCache, updateOnSdkTimedout, updateOnSdkUpdate 83 | }; 84 | } 85 | -------------------------------------------------------------------------------- /.github/workflows/ci-cd.yml: -------------------------------------------------------------------------------- 1 | name: ci 2 | 3 | on: 4 | push: 5 | branches: 6 | - '**' 7 | pull_request: 8 | branches: 9 | - main 10 | - development 11 | 12 | concurrency: 13 | group: ${{ github.workflow }}-${{ github.event.pull_request.number }} 14 | cancel-in-progress: true 15 | 16 | permissions: 17 | contents: read 18 | id-token: write 19 | 20 | jobs: 21 | build: 22 | name: Build 23 | runs-on: ubuntu-latest 24 | steps: 25 | - name: Checkout code 26 | uses: actions/checkout@v5 27 | with: 28 | fetch-depth: 0 29 | 30 | - name: Set up Node.js 31 | uses: actions/setup-node@v6 32 | with: 33 | node-version: 'lts/*' 34 | cache: 'npm' 35 | 36 | - name: npm ci 37 | run: npm ci 38 | 39 | - name: npm check 40 | run: npm run check 41 | 42 | - name: npm test 43 | run: npm run test -- --coverage 44 | 45 | - name: npm build 46 | run: BUILD_BRANCH=$(echo "${GITHUB_REF#refs/heads/}") npm run build 47 | 48 | - name: Set VERSION env 49 | run: echo "VERSION=$(cat package.json | jq -r .version)" >> $GITHUB_ENV 50 | 51 | - name: Store assets 52 | if: github.event_name == 'push' && (github.ref == 'refs/heads/main' || github.ref == 'refs/heads/development') 53 | uses: actions/upload-artifact@v5 54 | with: 55 | name: assets 56 | path: umd/ 57 | retention-days: 1 58 | 59 | upload-stage: 60 | name: Upload assets 61 | runs-on: ubuntu-latest 62 | needs: build 63 | if: github.event_name == 'push' && github.ref == 'refs/heads/development' 64 | strategy: 65 | matrix: 66 | environment: 67 | - stage 68 | include: 69 | - environment: stage 70 | account_id: "079419646996" 71 | bucket: split-public-stage 72 | 73 | steps: 74 | - name: Download assets 75 | uses: actions/download-artifact@v6 76 | with: 77 | name: assets 78 | path: umd 79 | 80 | - name: Display structure of assets 81 | run: ls -R 82 | working-directory: umd 83 | 84 | - name: Configure AWS credentials 85 | uses: aws-actions/configure-aws-credentials@v4 86 | with: 87 | role-to-assume: arn:aws:iam::${{ matrix.account_id }}:role/gha-public-assets-role 88 | aws-region: us-east-1 89 | 90 | - name: Upload to S3 91 | run: aws s3 sync $SOURCE_DIR s3://$BUCKET/$DEST_DIR $ARGS 92 | env: 93 | BUCKET: ${{ matrix.bucket }} 94 | SOURCE_DIR: ./umd 95 | DEST_DIR: sdk 96 | ARGS: --acl public-read --follow-symlinks --cache-control max-age=31536000,public 97 | 98 | upload-prod: 99 | name: Upload assets 100 | runs-on: ubuntu-latest 101 | needs: build 102 | if: github.event_name == 'push' && github.ref == 'refs/heads/main' 103 | strategy: 104 | matrix: 105 | environment: 106 | - prod 107 | include: 108 | - environment: prod 109 | account_id: "825951051969" 110 | bucket: split-public 111 | 112 | steps: 113 | - name: Download assets 114 | uses: actions/download-artifact@v6 115 | with: 116 | name: assets 117 | path: umd 118 | 119 | - name: Display structure of assets 120 | run: ls -R 121 | working-directory: umd 122 | 123 | - name: Configure AWS credentials 124 | uses: aws-actions/configure-aws-credentials@v4 125 | with: 126 | role-to-assume: arn:aws:iam::${{ matrix.account_id }}:role/gha-public-assets-role 127 | aws-region: us-east-1 128 | 129 | - name: Upload to S3 130 | run: aws s3 sync $SOURCE_DIR s3://$BUCKET/$DEST_DIR $ARGS 131 | env: 132 | BUCKET: ${{ matrix.bucket }} 133 | SOURCE_DIR: ./umd 134 | DEST_DIR: sdk 135 | ARGS: --acl public-read --follow-symlinks --cache-control max-age=31536000,public 136 | -------------------------------------------------------------------------------- /src/utils.ts: -------------------------------------------------------------------------------- 1 | import shallowEqual from 'shallowequal'; 2 | import { CONTROL, CONTROL_WITH_CONFIG } from './constants'; 3 | import { ISplitStatus } from './types'; 4 | 5 | function isString(val: unknown): val is string { 6 | return typeof val === 'string' || val instanceof String; 7 | } 8 | 9 | // Utils used to access singleton instances of Split factories and clients: 10 | 11 | export function getSplitClient(factory: SplitIO.IBrowserSDK, key?: SplitIO.SplitKey): SplitIO.IBrowserClient { 12 | // factory.client is an idempotent operation 13 | const client = key !== undefined ? factory.client(key) : factory.client(); 14 | 15 | // Remove EventEmitter warning emitted when using multiple SDK hooks or components. 16 | // Unlike JS SDK, users don't need to access the client directly, making the warning irrelevant. 17 | client.setMaxListeners && client.setMaxListeners(0); 18 | 19 | return client; 20 | } 21 | 22 | export function getStatus(client?: SplitIO.IBrowserClient): ISplitStatus { 23 | return client ? 24 | client.getStatus() : 25 | { 26 | isReady: false, 27 | isReadyFromCache: false, 28 | isTimedout: false, 29 | hasTimedout: false, 30 | isDestroyed: false, 31 | isOperational: false, 32 | lastUpdate: 0, 33 | }; 34 | } 35 | 36 | // Manage client attributes binding 37 | // @TODO should reset attributes rather than set/merge them, to keep SFP and hooks pure. 38 | export function initAttributes(client?: SplitIO.IBrowserClient, attributes?: SplitIO.Attributes) { 39 | if (client && attributes) client.setAttributes(attributes); 40 | } 41 | 42 | // Utils used to retrieve fallback or control treatments when the client is not operational: 43 | 44 | export function getTreatment(flagName: string, withConfig: true, factory?: SplitIO.IBrowserSDK): SplitIO.TreatmentWithConfig; 45 | export function getTreatment(flagName: string, withConfig: false, factory?: SplitIO.IBrowserSDK): SplitIO.Treatment; 46 | export function getTreatment(flagName: string, withConfig: boolean, factory?: SplitIO.IBrowserSDK): SplitIO.Treatment | SplitIO.TreatmentWithConfig; 47 | export function getTreatment(flagName: string, withConfig: boolean, factory?: SplitIO.IBrowserSDK) { 48 | if (factory && factory.settings.fallbackTreatments) { 49 | const fallbacks = factory.settings.fallbackTreatments; 50 | 51 | const treatment = fallbacks.byFlag?.[flagName] || fallbacks.global; 52 | 53 | if (treatment) { 54 | return isString(treatment) ? 55 | withConfig ? { treatment, config: null } : treatment : 56 | withConfig ? treatment : treatment.treatment; 57 | } 58 | } 59 | 60 | return withConfig ? CONTROL_WITH_CONFIG : CONTROL; 61 | } 62 | 63 | export function getTreatments(featureFlagNames: unknown, withConfig: true, factory?: SplitIO.IBrowserSDK): SplitIO.TreatmentsWithConfig; 64 | export function getTreatments(featureFlagNames: unknown, withConfig: false, factory?: SplitIO.IBrowserSDK): SplitIO.Treatments; 65 | export function getTreatments(featureFlagNames: unknown, withConfig: boolean, factory?: SplitIO.IBrowserSDK) { 66 | // validate feature flag names 67 | if (!Array.isArray(featureFlagNames)) return {}; 68 | 69 | featureFlagNames = featureFlagNames 70 | .filter((featureFlagName) => isString(featureFlagName)) 71 | .map((featureFlagName) => featureFlagName.trim()) 72 | .filter((featureFlagName) => featureFlagName.length > 0); 73 | 74 | // return control or fallback treatment for each validated feature flag name 75 | return (featureFlagNames as string[]).reduce((pValue: SplitIO.Treatments | SplitIO.TreatmentsWithConfig, featureFlagName: string) => { 76 | pValue[featureFlagName] = getTreatment(featureFlagName, withConfig, factory); 77 | return pValue; 78 | }, {}); 79 | } 80 | 81 | /** 82 | * Utils to memoize `client.getTreatments*` method calls to avoid duplicated impressions. 83 | * The result treatments are the same given the same `client` instance, `lastUpdate` timestamp, and list of feature flag names and attributes. 84 | */ 85 | 86 | export function argsAreEqual(newArgs: any[], lastArgs: any[]): boolean { 87 | return newArgs[0] === lastArgs[0] && // client 88 | newArgs[1] === lastArgs[1] && // lastUpdate 89 | shallowEqual(newArgs[2], lastArgs[2]) && // names 90 | shallowEqual(newArgs[3], lastArgs[3]) && // attributes 91 | shallowEqual(newArgs[4], lastArgs[4]) && // client attributes 92 | shallowEqual(newArgs[5], lastArgs[5]); // flagSets 93 | } 94 | -------------------------------------------------------------------------------- /src/__tests__/withSplitClient.test.tsx: -------------------------------------------------------------------------------- 1 | import * as React from 'react'; 2 | import { render } from '@testing-library/react'; 3 | 4 | /** Mocks */ 5 | import { mockSdk, Event } from './testUtils/mockSplitFactory'; 6 | jest.mock('@splitsoftware/splitio/client', () => { 7 | return { SplitFactory: mockSdk() }; 8 | }); 9 | import { SplitFactory as SplitSdk } from '@splitsoftware/splitio/client'; 10 | import { sdkBrowser } from './testUtils/sdkConfigs'; 11 | import * as SplitClient from '../SplitClient'; 12 | const SplitClientSpy = jest.spyOn(SplitClient, 'SplitClient'); 13 | import { testAttributesBinding, TestComponentProps } from './testUtils/utils'; 14 | 15 | /** Test target */ 16 | import { withSplitFactory } from '../withSplitFactory'; 17 | import { withSplitClient } from '../withSplitClient'; 18 | 19 | describe('withSplitClient', () => { 20 | 21 | beforeEach(() => { 22 | jest.clearAllMocks(); 23 | }); 24 | 25 | test('passes no-ready props to the child if client is not ready.', () => { 26 | const Component = withSplitFactory(sdkBrowser)( 27 | withSplitClient('user1')( 28 | ({ client, isReady, isReadyFromCache, hasTimedout, isTimedout, isDestroyed, lastUpdate }) => { 29 | expect(client).not.toBe(null); 30 | expect([isReady, isReadyFromCache, hasTimedout, isTimedout, isDestroyed, lastUpdate]).toStrictEqual([false, false, false, false, false, 0]); 31 | return null; 32 | } 33 | ) 34 | ); 35 | render(); 36 | }); 37 | 38 | test('passes ready props to the child if client is ready.', (done) => { 39 | const outerFactory = SplitSdk(sdkBrowser); 40 | (outerFactory as any).client().__emitter__.emit(Event.SDK_READY); 41 | (outerFactory.manager().names as jest.Mock).mockReturnValue(['split1']); 42 | outerFactory.client().ready().then(() => { 43 | const Component = withSplitFactory(undefined, outerFactory)( 44 | withSplitClient('user1')( 45 | ({ client, isReady, isReadyFromCache, hasTimedout, isTimedout, isDestroyed, lastUpdate }) => { 46 | expect(client).toBe(outerFactory.client('user1')); 47 | expect([isReady, isReadyFromCache, hasTimedout, isTimedout, isDestroyed, lastUpdate]).toStrictEqual([false, false, false, false, false, 0]); 48 | return null; 49 | } 50 | ) 51 | ); 52 | render(); 53 | done(); 54 | }); 55 | }); 56 | 57 | test('passes Split props and outer props to the child.', () => { 58 | const Component = withSplitFactory(sdkBrowser)<{ outerProp1: string, outerProp2: number }>( 59 | withSplitClient('user1')<{ outerProp1: string, outerProp2: number }>( 60 | ({ outerProp1, outerProp2, client, isReady, isReadyFromCache, hasTimedout, isTimedout, isDestroyed, lastUpdate }) => { 61 | expect(outerProp1).toBe('outerProp1'); 62 | expect(outerProp2).toBe(2); 63 | expect(client).not.toBe(null); 64 | expect([isReady, isReadyFromCache, hasTimedout, isTimedout, isDestroyed, lastUpdate]).toStrictEqual([false, false, false, false, false, 0]); 65 | return null; 66 | } 67 | ) 68 | ); 69 | render(); 70 | }); 71 | 72 | test('passes Status props to SplitClient.', () => { 73 | const updateOnSdkUpdate = true; 74 | const updateOnSdkTimedout = false; 75 | const updateOnSdkReady = true; 76 | const updateOnSdkReadyFromCache = false; 77 | const Component = withSplitFactory(sdkBrowser)( 78 | withSplitClient('user1')<{ outerProp1: string, outerProp2: number }>( 79 | () => null, updateOnSdkUpdate, updateOnSdkTimedout, updateOnSdkReady, updateOnSdkReadyFromCache 80 | ) 81 | ); 82 | render(); 83 | 84 | expect(SplitClientSpy).toHaveBeenCalledTimes(2); 85 | expect(SplitClientSpy.mock.calls[1][0]).toMatchObject({ 86 | updateOnSdkUpdate, 87 | updateOnSdkTimedout, 88 | updateOnSdkReady, 89 | updateOnSdkReadyFromCache, 90 | }); 91 | }); 92 | 93 | test('attributes binding test with utility', (done) => { 94 | 95 | function Component({ attributesFactory, attributesClient, splitKey, testSwitch, factory }: TestComponentProps) { 96 | const FactoryComponent = withSplitFactory(undefined, factory, attributesFactory)<{ attributesClient: SplitIO.Attributes, splitKey: any }>( 97 | ({ attributesClient, splitKey }) => { 98 | const ClientComponent = withSplitClient(splitKey, attributesClient)( 99 | () => { 100 | testSwitch(done, splitKey); 101 | return null; 102 | }) 103 | return ; 104 | } 105 | ) 106 | return 107 | } 108 | 109 | testAttributesBinding(Component); 110 | }); 111 | 112 | }); 113 | -------------------------------------------------------------------------------- /src/__tests__/useTrack.test.tsx: -------------------------------------------------------------------------------- 1 | import React, { useEffect } from 'react'; 2 | import { render, act } from '@testing-library/react'; 3 | 4 | /** Mocks */ 5 | import { mockSdk, Event, getLastInstance } from './testUtils/mockSplitFactory'; 6 | jest.mock('@splitsoftware/splitio/client', () => { 7 | return { SplitFactory: mockSdk() }; 8 | }); 9 | import { SplitFactory } from '@splitsoftware/splitio/client'; 10 | import { sdkBrowser } from './testUtils/sdkConfigs'; 11 | 12 | /** Test target */ 13 | import { SplitFactoryProvider } from '../SplitFactoryProvider'; 14 | import { useTrack } from '../useTrack'; 15 | import { useSplitClient } from '../useSplitClient'; 16 | import { EXCEPTION_NO_SFP } from '../constants'; 17 | 18 | describe('useTrack', () => { 19 | 20 | const tt = 'user'; 21 | const eventType = 'eventType'; 22 | const value = 10; 23 | const properties = { prop1: 'prop1' }; 24 | 25 | test('returns the track method of the main client of the factory at Split context provided by SplitFactoryProvider.', () => { 26 | const outerFactory = SplitFactory(sdkBrowser); 27 | let clientTrack; 28 | let trackResult; 29 | 30 | render( 31 | 32 | {React.createElement(() => { 33 | clientTrack = useTrack(); 34 | trackResult = clientTrack(tt, eventType, value, properties); 35 | 36 | const sameTrack = useTrack(sdkBrowser.core.key); 37 | expect(clientTrack).toBe(sameTrack); 38 | return null; 39 | })} 40 | , 41 | ); 42 | const track = outerFactory.client().track; 43 | expect(track).toBe(clientTrack); 44 | expect(track).toBeCalledWith(tt, eventType, value, properties); 45 | expect(track).toHaveReturnedWith(trackResult); 46 | }); 47 | 48 | test('returns the track method of a new client given a splitKey.', () => { 49 | const outerFactory = SplitFactory(sdkBrowser); 50 | let trackResult; 51 | 52 | render( 53 | 54 | {React.createElement(() => { 55 | const clientTrack = useTrack('user2'); 56 | trackResult = clientTrack(tt, eventType, value, properties); 57 | return null; 58 | })} 59 | , 60 | ); 61 | const track = outerFactory.client('user2').track; 62 | expect(track).toBeCalledWith(tt, eventType, value, properties); 63 | expect(track).toHaveReturnedWith(trackResult); 64 | }); 65 | 66 | test('throws error if invoked outside of SplitFactoryProvider.', () => { 67 | expect(() => { 68 | render( 69 | React.createElement(() => { 70 | const track = useTrack('user2'); 71 | track(tt, eventType, value, properties); 72 | return null; 73 | }), 74 | ); 75 | }).toThrow(EXCEPTION_NO_SFP); 76 | }); 77 | 78 | test('returns the track method of the client at Split context updated by SplitFactoryProvider (config prop).', () => { 79 | const InnerComponent = ({ splitKey }: { splitKey?: string }) => { 80 | const clientTrack = useTrack(splitKey); 81 | 82 | const { client } = useSplitClient({ splitKey }); 83 | expect(clientTrack).toBe(client!.track); 84 | 85 | clientTrack(tt, eventType, value, properties); 86 | 87 | useEffect(() => { 88 | clientTrack(tt, eventType, value, properties); 89 | }, [clientTrack]); 90 | return null; 91 | } 92 | const App = ({ splitKey }: { splitKey?: string }) => { 93 | return ( 94 | 95 | ) 96 | }; 97 | const wrapper = render(); 98 | 99 | act(() => getLastInstance(SplitFactory).client().__emitter__.emit(Event.SDK_READY_FROM_CACHE)); 100 | act(() => getLastInstance(SplitFactory).client().__emitter__.emit(Event.SDK_READY)); 101 | 102 | wrapper.rerender(); // `clientTrack` dependency changed 103 | 104 | act(() => getLastInstance(SplitFactory).client().__emitter__.emit(Event.SDK_UPDATE)); 105 | 106 | let track = getLastInstance(SplitFactory).client().track; 107 | expect(track).toBeCalledWith(tt, eventType, value, properties); 108 | expect(track).toBeCalledTimes(4); // 3 from render + 1 from useEffect 109 | 110 | track = getLastInstance(SplitFactory).client('user2').track; 111 | expect(track).toBeCalledWith(tt, eventType, value, properties); 112 | expect(track).toBeCalledTimes(2); // 1 from render + 1 from useEffect (`clientTrack` dependency changed) 113 | }); 114 | 115 | test('does not re-render on SDK events', () => { 116 | render( 117 | 118 | {React.createElement(() => { 119 | const clientTrack = useTrack(); 120 | clientTrack(tt, eventType, value, properties); 121 | 122 | return null; 123 | })} 124 | , 125 | ); 126 | 127 | act(() => getLastInstance(SplitFactory).client().__emitter__.emit(Event.SDK_READY_TIMED_OUT)); 128 | act(() => getLastInstance(SplitFactory).client().__emitter__.emit(Event.SDK_READY_FROM_CACHE)); 129 | act(() => getLastInstance(SplitFactory).client().__emitter__.emit(Event.SDK_READY)); 130 | act(() => getLastInstance(SplitFactory).client().__emitter__.emit(Event.SDK_UPDATE)); 131 | 132 | expect(getLastInstance(SplitFactory).client().track).toBeCalledTimes(1); 133 | }); 134 | 135 | }); 136 | -------------------------------------------------------------------------------- /src/__tests__/testUtils/utils.tsx: -------------------------------------------------------------------------------- 1 | import * as React from 'react'; 2 | import { render } from '@testing-library/react'; 3 | import { ISplitStatus, IUpdateProps } from '../../types'; 4 | const { SplitFactory: originalSplitFactory } = jest.requireActual('@splitsoftware/splitio/client'); 5 | 6 | export interface TestComponentProps { 7 | attributesFactory: SplitIO.Attributes, 8 | attributesClient: SplitIO.Attributes, 9 | splitKey: SplitIO.SplitKey, 10 | testSwitch: (done: jest.DoneCallback, splitKey?: SplitIO.SplitKey) => void, 11 | factory: SplitIO.IBrowserSDK 12 | } 13 | 14 | export function newSplitFactoryLocalhostInstance() { 15 | return originalSplitFactory({ 16 | core: { 17 | authorizationKey: 'localhost', 18 | key: 'emma' 19 | }, 20 | features: { 21 | test_split: 'on' 22 | } 23 | }) 24 | } 25 | 26 | export function testAttributesBinding(Component: React.FunctionComponent | React.ComponentClass) { 27 | 28 | let renderTimes = 0 29 | const factory = newSplitFactoryLocalhostInstance() 30 | 31 | const mainClient = factory.client(); 32 | const mainClientSpy = { 33 | setAttributes: jest.spyOn(mainClient, 'setAttributes'), 34 | clearAttributes: jest.spyOn(mainClient, 'clearAttributes') 35 | } 36 | 37 | const client = factory.client('user1', 'user'); 38 | const clientSpy = { 39 | setAttributes: jest.spyOn(client, 'setAttributes'), 40 | clearAttributes: jest.spyOn(client, 'clearAttributes'), 41 | } 42 | 43 | // this function is called inside the received component 44 | function attributesBindingSwitch(done: any, splitKey?: SplitIO.SplitKey) { 45 | renderTimes++; 46 | switch (renderTimes) { 47 | case 1: 48 | if (splitKey) { 49 | expect(mainClientSpy.setAttributes).lastCalledWith({ at1: 'at1' }); 50 | expect(mainClient.getAttributes()).toStrictEqual({ at1: 'at1' }); 51 | expect(clientSpy.setAttributes).lastCalledWith({ at2: 'at2' }); 52 | expect(client.getAttributes()).toStrictEqual({ at2: 'at2' }); 53 | } else { 54 | expect(mainClientSpy.setAttributes.mock.calls).toEqual([[{ at1: 'at1' }], [{ at2: 'at2' }]]); 55 | expect(mainClient.getAttributes()).toStrictEqual({ at1: 'at1', at2: 'at2' }); 56 | expect(clientSpy.setAttributes).toBeCalledTimes(0); 57 | } 58 | break; 59 | case 2: 60 | if (splitKey) { 61 | expect(mainClient.getAttributes()).toStrictEqual({ at1: 'at1' }); 62 | expect(clientSpy.setAttributes).lastCalledWith({ at3: 'at3' }); 63 | expect(client.getAttributes()).toStrictEqual({ at2: 'at2', at3: 'at3' }); 64 | } else { 65 | expect(mainClientSpy.setAttributes).lastCalledWith({ at3: 'at3' }); 66 | expect(mainClient.getAttributes()).toStrictEqual({ at1: 'at1', at2: 'at2', at3: 'at3' }); 67 | expect(clientSpy.setAttributes).toBeCalledTimes(0); 68 | } 69 | break; 70 | case 3: 71 | if (splitKey) { 72 | expect(mainClientSpy.setAttributes).lastCalledWith({ at4: 'at4' }); 73 | expect(mainClient.getAttributes()).toStrictEqual({ at1: 'at1', at4: 'at4' }); 74 | expect(client.getAttributes()).toStrictEqual({ at2: 'at2', at3: 'at3' }); 75 | } else { 76 | expect(mainClientSpy.setAttributes).lastCalledWith({ at4: 'at4' }); 77 | expect(mainClient.getAttributes()).toStrictEqual({ at1: 'at1', at2: 'at2', at3: 'at3', at4: 'at4' }); 78 | expect(clientSpy.setAttributes).toBeCalledTimes(0); 79 | } 80 | break; 81 | case 4: 82 | if (splitKey) { 83 | expect(mainClient.getAttributes()).toStrictEqual({ at1: 'at1', at4: 'at4' }); 84 | expect(client.getAttributes()).toStrictEqual({ at2: 'at2', at3: 'at3' }); 85 | } else { 86 | expect(mainClient.getAttributes()).toStrictEqual({ at1: 'at1', at2: 'at2', at3: 'at3', at4: 'at4' }); 87 | expect(clientSpy.setAttributes).toBeCalledTimes(0); 88 | mainClient.destroy().then(done); 89 | } 90 | break; 91 | } 92 | } 93 | 94 | let wrapper = render(); 95 | 96 | wrapper.rerender(); 97 | wrapper.rerender(); 98 | wrapper.rerender(); 99 | 100 | wrapper.unmount() 101 | 102 | // clear clients attributes and spies for 2nd round of tests 103 | mainClientSpy.setAttributes.mockClear(); 104 | mainClientSpy.clearAttributes.mockClear(); 105 | mainClient.clearAttributes(); 106 | clientSpy.setAttributes.mockClear(); 107 | clientSpy.clearAttributes.mockClear(); 108 | client.clearAttributes(); 109 | renderTimes = 0; 110 | 111 | // With splitKey undefined, mainClient and client refer to the same client instance. 112 | wrapper = render(); 113 | 114 | wrapper.rerender(); 115 | wrapper.rerender(); 116 | wrapper.rerender(); 117 | } 118 | 119 | export const INITIAL_STATUS: ISplitStatus & IUpdateProps = { 120 | isReady: false, 121 | isReadyFromCache: false, 122 | isTimedout: false, 123 | hasTimedout: false, 124 | lastUpdate: 0, 125 | isDestroyed: false, 126 | isOperational: false, 127 | updateOnSdkReady: true, 128 | updateOnSdkReadyFromCache: true, 129 | updateOnSdkTimedout: true, 130 | updateOnSdkUpdate: true, 131 | } 132 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # Split SDK for React 2 | 3 | [![npm version](https://badge.fury.io/js/%40splitsoftware%2Fsplitio-react.svg)](https://badge.fury.io/js/%40splitsoftware%2Fsplitio-react) [![Build Status](https://github.com/splitio/react-client/actions/workflows/ci-cd.yml/badge.svg)](https://github.com/splitio/react-client/actions/workflows/ci-cd.yml) 4 | 5 | ## Overview 6 | This SDK is designed to work with Split, the platform for controlled rollouts, which serves features to your users via feature flag to manage your complete customer experience. 7 | 8 | [![Twitter Follow](https://img.shields.io/twitter/follow/splitsoftware.svg?style=social&label=Follow&maxAge=1529000)](https://twitter.com/intent/follow?screen_name=splitsoftware) 9 | 10 | ## Compatibility 11 | 12 | This SDK is compatible with React 16.8.0 and above, since it uses [React Hooks API](https://react.dev/reference/react/hooks) introduced in that version. 13 | 14 | ## Getting started 15 | Below is a simple example that describes the instantiation and most basic usage of our SDK: 16 | 17 | ```javascript 18 | import React from 'react'; 19 | 20 | // Import SDK functions 21 | import { SplitFactoryProvider, useTreatment } from '@splitsoftware/splitio-react'; 22 | 23 | // Define your config object 24 | const CONFIG = { 25 | core: { 26 | authorizationKey: 'YOUR_SDK_KEY', 27 | key: 'CUSTOMER_ID' 28 | } 29 | }; 30 | 31 | function MyComponent() { 32 | // Evaluate a feature flag with useTreatment hook 33 | const { treatment, isReady } = useTreatment({ name: 'FEATURE_FLAG_NAME' }); 34 | 35 | // Check SDK readiness using isReady prop 36 | if (!isReady) return
Loading SDK ...
; 37 | 38 | if (treatment === 'on') { 39 | // return JSX for 'on' treatment 40 | } else if (treatment === 'off') { 41 | // return JSX for 'off' treatment 42 | } else { 43 | // return JSX for 'control' treatment 44 | }; 45 | } 46 | 47 | function MyApp() { 48 | return ( 49 | // Use SplitFactoryProvider to instantiate the SDK and makes it available to nested components 50 | 51 | 52 | 53 | ); 54 | } 55 | ``` 56 | 57 | Please refer to [our official docs](https://developer.harness.io/docs/feature-management-experimentation/sdks-and-infrastructure/client-side-sdks/react-sdk/) to learn about all the functionality provided by our SDK and the configuration options available for tailoring it to your current application setup. 58 | 59 | ## Submitting issues 60 | 61 | The Split team monitors all issues submitted to this [issue tracker](https://github.com/splitio/react-client/issues). We encourage you to use this issue tracker to submit any bug reports, feedback, and feature enhancements. We'll do our best to respond in a timely manner. 62 | 63 | ## Contributing 64 | Please see [Contributors Guide](CONTRIBUTORS-GUIDE.md) to find all you need to submit a Pull Request (PR). 65 | 66 | ## License 67 | Licensed under the Apache License, Version 2.0. See: [Apache License](http://www.apache.org/licenses/). 68 | 69 | ## About Split 70 | 71 | Split is the leading Feature Delivery Platform for engineering teams that want to confidently deploy features as fast as they can develop them. Split’s fine-grained management, real-time monitoring, and data-driven experimentation ensure that new features will improve the customer experience without breaking or degrading performance. Companies like Twilio, Salesforce, GoDaddy and WePay trust Split to power their feature delivery. 72 | 73 | To learn more about Split, contact hello@split.io, or get started with feature flags for free at https://www.split.io/signup. 74 | 75 | Split has built and maintains SDKs for: 76 | 77 | * .NET [Github](https://github.com/splitio/dotnet-client) [Docs](https://developer.harness.io/docs/feature-management-experimentation/sdks-and-infrastructure/server-side-sdks/net-sdk/) 78 | * Android [Github](https://github.com/splitio/android-client) [Docs](https://developer.harness.io/docs/feature-management-experimentation/sdks-and-infrastructure/client-side-sdks/android-sdk/) 79 | * Angular [Github](https://github.com/splitio/angular-sdk-plugin) [Docs](https://developer.harness.io/docs/feature-management-experimentation/sdks-and-infrastructure/client-side-sdks/angular-utilities/) 80 | * Elixir thin-client [Github](https://github.com/splitio/elixir-thin-client) [Docs](https://developer.harness.io/docs/feature-management-experimentation/sdks-and-infrastructure/server-side-sdks/elixir-thin-client-sdk/) 81 | * Flutter [Github](https://github.com/splitio/flutter-sdk-plugin) [Docs](https://developer.harness.io/docs/feature-management-experimentation/sdks-and-infrastructure/client-side-sdks/flutter-plugin/) 82 | * GO [Github](https://github.com/splitio/go-client) [Docs](https://developer.harness.io/docs/feature-management-experimentation/sdks-and-infrastructure/server-side-sdks/go-sdk/) 83 | * iOS [Github](https://github.com/splitio/ios-client) [Docs](https://developer.harness.io/docs/feature-management-experimentation/sdks-and-infrastructure/client-side-sdks/ios-sdk/) 84 | * Java [Github](https://github.com/splitio/java-client) [Docs](https://developer.harness.io/docs/feature-management-experimentation/sdks-and-infrastructure/server-side-sdks/java-sdk/) 85 | * JavaScript [Github](https://github.com/splitio/javascript-client) [Docs](https://developer.harness.io/docs/feature-management-experimentation/sdks-and-infrastructure/client-side-sdks/javascript-sdk/) 86 | * JavaScript for Browser [Github](https://github.com/splitio/javascript-browser-client) [Docs](https://developer.harness.io/docs/feature-management-experimentation/sdks-and-infrastructure/client-side-sdks/browser-sdk/) 87 | * Node.js [Github](https://github.com/splitio/javascript-client) [Docs](https://developer.harness.io/docs/feature-management-experimentation/sdks-and-infrastructure/server-side-sdks/nodejs-sdk/) 88 | * PHP [Github](https://github.com/splitio/php-client) [Docs](https://developer.harness.io/docs/feature-management-experimentation/sdks-and-infrastructure/server-side-sdks/php-sdk/) 89 | * PHP thin-client [Github](https://github.com/splitio/php-thin-client) [Docs](https://developer.harness.io/docs/feature-management-experimentation/sdks-and-infrastructure/server-side-sdks/php-thin-client-sdk/) 90 | * Python [Github](https://github.com/splitio/python-client) [Docs](https://developer.harness.io/docs/feature-management-experimentation/sdks-and-infrastructure/server-side-sdks/python-sdk/) 91 | * React [Github](https://github.com/splitio/react-client) [Docs](https://developer.harness.io/docs/feature-management-experimentation/sdks-and-infrastructure/client-side-sdks/react-sdk/) 92 | * React Native [Github](https://github.com/splitio/react-native-client) [Docs](https://developer.harness.io/docs/feature-management-experimentation/sdks-and-infrastructure/client-side-sdks/react-native-sdk/) 93 | * Redux [Github](https://github.com/splitio/redux-client) [Docs](https://developer.harness.io/docs/feature-management-experimentation/sdks-and-infrastructure/client-side-sdks/redux-sdk/) 94 | * Ruby [Github](https://github.com/splitio/ruby-client) [Docs](https://developer.harness.io/docs/feature-management-experimentation/sdks-and-infrastructure/server-side-sdks/ruby-sdk/) 95 | 96 | For a comprehensive list of open source projects visit our [Github page](https://github.com/splitio?utf8=%E2%9C%93&query=%20only%3Apublic%20). 97 | 98 | **Learn more about Split:** 99 | 100 | Visit [split.io/product](https://www.split.io/product) for an overview of Split, or visit our documentation at [help.split.io](https://help.split.io) for more detailed information. 101 | -------------------------------------------------------------------------------- /src/__tests__/testUtils/mockSplitFactory.ts: -------------------------------------------------------------------------------- 1 | import { EventEmitter } from 'events'; 2 | import jsSdkPackageJson from '@splitsoftware/splitio/package.json'; 3 | import reactSdkPackageJson from '../../../package.json'; 4 | import { CONTROL, CONTROL_WITH_CONFIG } from '../../constants'; 5 | 6 | export const jsSdkVersion = `javascript-${jsSdkPackageJson.version}`; 7 | export const reactSdkVersion = `react-${reactSdkPackageJson.version}`; 8 | 9 | export const Event = { 10 | SDK_READY_TIMED_OUT: 'init::timeout', 11 | SDK_READY: 'init::ready', 12 | SDK_READY_FROM_CACHE: 'init::cache-ready', 13 | SDK_UPDATE: 'state::update', 14 | }; 15 | 16 | const DEFAULT_LOGGER: SplitIO.Logger = { 17 | error(msg) { console.log('[ERROR] splitio => ' + msg); }, 18 | warn(msg) { console.log('[WARN] splitio => ' + msg); }, 19 | info(msg) { console.log('[INFO] splitio => ' + msg); }, 20 | debug(msg) { console.log('[DEBUG] splitio => ' + msg); }, 21 | }; 22 | 23 | function parseKey(key: SplitIO.SplitKey): SplitIO.SplitKey { 24 | if (key && typeof key === 'object' && key.constructor === Object) { 25 | return { 26 | matchingKey: (key as SplitIO.SplitKeyObject).matchingKey, 27 | bucketingKey: (key as SplitIO.SplitKeyObject).bucketingKey, 28 | }; 29 | } else { 30 | return { 31 | matchingKey: (key as string), 32 | bucketingKey: (key as string), 33 | }; 34 | } 35 | } 36 | function buildInstanceId(key: any, trafficType?: string) { 37 | return `${key.matchingKey ? key.matchingKey : key}-${key.bucketingKey ? key.bucketingKey : key}-${trafficType !== undefined ? trafficType : ''}`; 38 | } 39 | 40 | export function mockSdk() { 41 | 42 | return jest.fn((config: SplitIO.IBrowserSettings, __updateModules) => { 43 | 44 | function mockClient(_key: SplitIO.SplitKey) { 45 | // Readiness 46 | let isReady = false; 47 | let isReadyFromCache = false; 48 | let hasTimedout = false; 49 | let isDestroyed = false; 50 | let lastUpdate = 0; 51 | 52 | function syncLastUpdate() { 53 | const dateNow = Date.now(); 54 | lastUpdate = dateNow > lastUpdate ? dateNow : lastUpdate + 1; 55 | } 56 | 57 | const __emitter__ = new EventEmitter(); 58 | __emitter__.on(Event.SDK_READY, () => { isReady = true; isReadyFromCache = true; syncLastUpdate(); }); 59 | __emitter__.on(Event.SDK_READY_FROM_CACHE, () => { isReadyFromCache = true; syncLastUpdate(); }); 60 | __emitter__.on(Event.SDK_READY_TIMED_OUT, () => { hasTimedout = true; syncLastUpdate(); }); 61 | __emitter__.on(Event.SDK_UPDATE, () => { syncLastUpdate(); }); 62 | 63 | let attributesCache = {}; 64 | 65 | // Client methods 66 | const track: jest.Mock = jest.fn(() => { 67 | return true; 68 | }); 69 | const getTreatment: jest.Mock = jest.fn((featureFlagName: string) => { 70 | return typeof featureFlagName === 'string' ? 'on' : CONTROL; 71 | }); 72 | const getTreatments: jest.Mock = jest.fn((featureFlagNames: string[]) => { 73 | return featureFlagNames.reduce((result: SplitIO.Treatments, featureName: string) => { 74 | result[featureName] = 'on'; 75 | return result; 76 | }, {}); 77 | }); 78 | const getTreatmentsByFlagSets: jest.Mock = jest.fn((flagSets: string[]) => { 79 | return flagSets.reduce((result: SplitIO.Treatments, flagSet: string) => { 80 | result[flagSet + '_feature_flag'] = 'on'; 81 | return result; 82 | }, {}); 83 | }); 84 | const getTreatmentWithConfig: jest.Mock = jest.fn((featureFlagName: string) => { 85 | return typeof featureFlagName === 'string' ? { treatment: 'on', config: null } : CONTROL_WITH_CONFIG; 86 | }); 87 | const getTreatmentsWithConfig: jest.Mock = jest.fn((featureFlagNames: string[]) => { 88 | return featureFlagNames.reduce((result: SplitIO.TreatmentsWithConfig, featureName: string) => { 89 | result[featureName] = { treatment: 'on', config: null }; 90 | return result; 91 | }, {}); 92 | }); 93 | const getTreatmentsWithConfigByFlagSets: jest.Mock = jest.fn((flagSets: string[]) => { 94 | return flagSets.reduce((result: SplitIO.TreatmentsWithConfig, flagSet: string) => { 95 | result[flagSet + '_feature_flag'] = { treatment: 'on', config: null }; 96 | return result; 97 | }, {}); 98 | }); 99 | const setAttributes: jest.Mock = jest.fn((attributes) => { 100 | attributesCache = Object.assign(attributesCache, attributes); 101 | return true; 102 | }); 103 | const clearAttributes: jest.Mock = jest.fn(() => { 104 | attributesCache = {}; 105 | return true; 106 | }); 107 | const getAttributes: jest.Mock = jest.fn(() => { 108 | return attributesCache; 109 | }); 110 | const ready: jest.Mock = jest.fn(() => { 111 | return new Promise((res, rej) => { 112 | if (isReady) res(); 113 | else { __emitter__.on(Event.SDK_READY, res); } 114 | if (hasTimedout) rej(); 115 | else { __emitter__.on(Event.SDK_READY_TIMED_OUT, rej); } 116 | }); 117 | }); 118 | const getStatus = () => ({ 119 | isReady, 120 | isReadyFromCache, 121 | isTimedout: hasTimedout && !isReady, 122 | hasTimedout, 123 | isDestroyed, 124 | isOperational: isReadyFromCache && !isDestroyed, 125 | lastUpdate, 126 | }); 127 | const destroy: jest.Mock = jest.fn(() => { 128 | isDestroyed = true; 129 | syncLastUpdate(); 130 | // __emitter__.removeAllListeners(); 131 | return Promise.resolve(); 132 | }); 133 | 134 | return Object.assign(Object.create(__emitter__), { 135 | getTreatment, 136 | getTreatments, 137 | getTreatmentsByFlagSets, 138 | getTreatmentWithConfig, 139 | getTreatmentsWithConfig, 140 | getTreatmentsWithConfigByFlagSets, 141 | track, 142 | ready, 143 | destroy, 144 | Event, 145 | setAttributes, 146 | clearAttributes, 147 | getAttributes, 148 | getStatus, 149 | // EventEmitter exposed to trigger events manually 150 | __emitter__, 151 | // Restore the mock client to its initial NO-READY status. 152 | // Useful when you want to reuse the same mock between tests after emitting events or destroying the instance. 153 | __restore() { 154 | isReady = isReadyFromCache = hasTimedout = isDestroyed = false; 155 | lastUpdate = 0; 156 | } 157 | }); 158 | } 159 | 160 | // Manager 161 | const names: jest.Mock = jest.fn().mockReturnValue([]); 162 | const manager: jest.Mock = jest.fn().mockReturnValue({ names }); 163 | 164 | // Cache of clients 165 | const __clients__: { [instanceId: string]: any } = {}; 166 | const client = jest.fn((key?: string) => { 167 | const clientKey = key || parseKey(config.core.key); 168 | const instanceId = buildInstanceId(clientKey); 169 | return __clients__[instanceId] || (__clients__[instanceId] = mockClient(clientKey)); 170 | }); 171 | 172 | // Factory destroy 173 | const destroy = jest.fn(() => { 174 | return Promise.all(Object.keys(__clients__).map(instanceId => __clients__[instanceId].destroy())); 175 | }); 176 | 177 | // SDK factory 178 | const factory = { 179 | client, 180 | manager, 181 | destroy, 182 | __names__: names, 183 | __clients__, 184 | settings: Object.assign({ 185 | version: jsSdkVersion, 186 | log: DEFAULT_LOGGER 187 | }, config), 188 | }; 189 | 190 | if (__updateModules) __updateModules(factory); 191 | 192 | return factory; 193 | }); 194 | 195 | } 196 | 197 | export function getLastInstance(SplitFactoryMock: any) { 198 | return SplitFactoryMock.mock.results.slice(-1)[0].value; 199 | } 200 | -------------------------------------------------------------------------------- /src/__tests__/useTreatment.test.tsx: -------------------------------------------------------------------------------- 1 | import * as React from 'react'; 2 | import { act, render } from '@testing-library/react'; 3 | 4 | /** Mocks */ 5 | import { mockSdk, Event } from './testUtils/mockSplitFactory'; 6 | jest.mock('@splitsoftware/splitio/client', () => { 7 | return { SplitFactory: mockSdk() }; 8 | }); 9 | import { SplitFactory } from '@splitsoftware/splitio/client'; 10 | import { sdkBrowser, sdkBrowserWithConfig } from './testUtils/sdkConfigs'; 11 | import { CONTROL, EXCEPTION_NO_SFP } from '../constants'; 12 | 13 | /** Test target */ 14 | import { SplitFactoryProvider } from '../SplitFactoryProvider'; 15 | import { useTreatment } from '../useTreatment'; 16 | import { SplitContext } from '../SplitContext'; 17 | import { IUseTreatmentResult } from '../types'; 18 | 19 | describe('useTreatment', () => { 20 | 21 | const featureFlagName = 'split1'; 22 | const attributes = { att1: 'att1' }; 23 | const properties = { prop1: 'prop1' }; 24 | 25 | test('returns the treatment evaluated by the main client of the factory at Split context, or control if the client is not operational.', () => { 26 | const outerFactory = SplitFactory(sdkBrowser); 27 | const client: any = outerFactory.client(); 28 | let treatment: SplitIO.Treatment; 29 | 30 | render( 31 | 32 | {React.createElement(() => { 33 | treatment = useTreatment({ name: featureFlagName, attributes, properties }).treatment; 34 | return null; 35 | })} 36 | 37 | ); 38 | 39 | // returns control treatment if not operational (SDK not ready or destroyed), without calling `getTreatment` method 40 | expect(client.getTreatment).not.toBeCalled(); 41 | expect(treatment!).toEqual(CONTROL); 42 | 43 | // once operational (SDK_READY), it evaluates feature flags 44 | act(() => client.__emitter__.emit(Event.SDK_READY)); 45 | 46 | expect(client.getTreatment).toBeCalledWith(featureFlagName, attributes, { properties }); 47 | expect(client.getTreatment).toHaveReturnedWith(treatment!); 48 | }); 49 | 50 | test('returns the treatments from a new client given a splitKey, and re-evaluates on SDK events.', () => { 51 | const outerFactory = SplitFactory(sdkBrowser); 52 | const client: any = outerFactory.client('user2'); 53 | let renderTimes = 0; 54 | 55 | render( 56 | 57 | {React.createElement(() => { 58 | const treatment = useTreatment({ name: featureFlagName, attributes, properties, splitKey: 'user2', updateOnSdkUpdate: false }).treatment; 59 | 60 | renderTimes++; 61 | switch (renderTimes) { 62 | case 1: 63 | // returns control if not operational (SDK not ready), without calling `getTreatment` method 64 | expect(client.getTreatment).not.toBeCalled(); 65 | expect(treatment).toEqual(CONTROL); 66 | break; 67 | case 2: 68 | case 3: 69 | // once operational (SDK_READY or SDK_READY_FROM_CACHE), it evaluates feature flags 70 | expect(client.getTreatment).toHaveBeenLastCalledWith(featureFlagName, attributes, { properties }); 71 | expect(client.getTreatment).toHaveLastReturnedWith(treatment); 72 | break; 73 | default: 74 | throw new Error('Unexpected render'); 75 | } 76 | 77 | return null; 78 | })} 79 | 80 | ); 81 | 82 | act(() => client.__emitter__.emit(Event.SDK_READY_FROM_CACHE)); 83 | act(() => client.__emitter__.emit(Event.SDK_READY)); 84 | act(() => client.__emitter__.emit(Event.SDK_UPDATE)); 85 | expect(client.getTreatment).toBeCalledTimes(2); 86 | }); 87 | 88 | test('throws error if invoked outside of SplitFactoryProvider.', () => { 89 | expect(() => { 90 | render( 91 | React.createElement(() => { 92 | useTreatment({ name: featureFlagName, attributes }).treatment; 93 | return null; 94 | }) 95 | ); 96 | }).toThrow(EXCEPTION_NO_SFP); 97 | }); 98 | 99 | test('must update on SDK events', async () => { 100 | const outerFactory = SplitFactory(sdkBrowser); 101 | const mainClient = outerFactory.client() as any; 102 | const user2Client = outerFactory.client('user_2') as any; 103 | 104 | let countSplitContext = 0, countUseTreatment = 0, countUseTreatmentUser2 = 0, countUseTreatmentUser2WithoutUpdate = 0; 105 | const lastUpdateSetUser2 = new Set(); 106 | const lastUpdateSetUser2WithUpdate = new Set(); 107 | 108 | function validateTreatment({ treatment, isReady, isReadyFromCache }: IUseTreatmentResult) { 109 | if (isReady || isReadyFromCache) { 110 | expect(treatment).toEqual('on') 111 | } else { 112 | expect(treatment).toEqual('control') 113 | } 114 | } 115 | 116 | render( 117 | 118 | <> 119 | 120 | {() => countSplitContext++} 121 | 122 | {React.createElement(() => { 123 | const context = useTreatment({ name: 'split_test', attributes: { att1: 'att1' } }); 124 | expect(context.client).toBe(mainClient); // Assert that the main client was retrieved. 125 | validateTreatment(context); 126 | countUseTreatment++; 127 | return null; 128 | })} 129 | {React.createElement(() => { 130 | const context = useTreatment({ name: 'split_test', splitKey: 'user_2' }); 131 | expect(context.client).toBe(user2Client); 132 | validateTreatment(context); 133 | lastUpdateSetUser2.add(context.lastUpdate); 134 | countUseTreatmentUser2++; 135 | return null; 136 | })} 137 | {React.createElement(() => { 138 | const context = useTreatment({ name: 'split_test', splitKey: 'user_2', updateOnSdkUpdate: false }); 139 | expect(context.client).toBe(user2Client); 140 | validateTreatment(context); 141 | lastUpdateSetUser2WithUpdate.add(context.lastUpdate); 142 | countUseTreatmentUser2WithoutUpdate++; 143 | return null; 144 | })} 145 | 146 | 147 | ); 148 | 149 | act(() => mainClient.__emitter__.emit(Event.SDK_READY_FROM_CACHE)); 150 | act(() => mainClient.__emitter__.emit(Event.SDK_READY)); 151 | act(() => mainClient.__emitter__.emit(Event.SDK_UPDATE)); 152 | act(() => user2Client.__emitter__.emit(Event.SDK_READY_FROM_CACHE)); 153 | act(() => user2Client.__emitter__.emit(Event.SDK_READY)); 154 | act(() => user2Client.__emitter__.emit(Event.SDK_UPDATE)); 155 | 156 | // SplitFactoryProvider renders once 157 | expect(countSplitContext).toEqual(1); 158 | 159 | // If useTreatment evaluates with the main client and have default update options, it re-renders for each main client event. 160 | expect(countUseTreatment).toEqual(4); 161 | expect(mainClient.getTreatment).toHaveBeenCalledTimes(3); // when ready from cache, ready and update 162 | expect(mainClient.getTreatment).toHaveBeenLastCalledWith('split_test', { att1: 'att1' }, undefined); 163 | 164 | // If useTreatment evaluates with a different client and have default update options, it re-renders for each event of the new client. 165 | expect(countUseTreatmentUser2).toEqual(4); 166 | expect(lastUpdateSetUser2.size).toEqual(4); 167 | // If it is used with `updateOnSdkUpdate: false`, it doesn't render when the client emits an SDK_UPDATE event. 168 | expect(countUseTreatmentUser2WithoutUpdate).toEqual(3); 169 | expect(lastUpdateSetUser2WithUpdate.size).toEqual(3); 170 | expect(user2Client.getTreatment).toHaveBeenCalledTimes(5); // when ready from cache x2, ready x2 and update x1 171 | expect(user2Client.getTreatment).toHaveBeenLastCalledWith('split_test', undefined, undefined); 172 | }); 173 | 174 | test('returns fallback treatment if the client is not operational', () => { 175 | render( 176 | 177 | {React.createElement(() => { 178 | expect(useTreatment({ name: featureFlagName, attributes, properties }).treatment).toEqual('control_global'); 179 | expect(useTreatment({ name: 'ff1', attributes, properties }).treatment).toEqual('control_ff1'); 180 | return null; 181 | })} 182 | 183 | ); 184 | }); 185 | 186 | }); 187 | -------------------------------------------------------------------------------- /src/__tests__/SplitFactoryProvider.test.tsx: -------------------------------------------------------------------------------- 1 | import * as React from 'react'; 2 | import { render, act } from '@testing-library/react'; 3 | 4 | /** Mocks */ 5 | import { mockSdk, Event, getLastInstance } from './testUtils/mockSplitFactory'; 6 | jest.mock('@splitsoftware/splitio/client', () => { 7 | return { SplitFactory: mockSdk() }; 8 | }); 9 | import { SplitFactory } from '@splitsoftware/splitio/client'; 10 | import { sdkBrowser } from './testUtils/sdkConfigs'; 11 | const logSpy = jest.spyOn(console, 'log'); 12 | 13 | /** Test target */ 14 | import { SplitFactoryProvider } from '../SplitFactoryProvider'; 15 | import { SplitContext, useSplitContext } from '../SplitContext'; 16 | import { getStatus } from '../utils'; 17 | import { WARN_SF_CONFIG_AND_FACTORY } from '../constants'; 18 | import { INITIAL_STATUS } from './testUtils/utils'; 19 | import { useSplitClient } from '../useSplitClient'; 20 | 21 | describe('SplitFactoryProvider', () => { 22 | 23 | test('passes no-ready properties, no factory and no client to the context if initialized without a config and factory props.', () => { 24 | render( 25 | 26 | {React.createElement(() => { 27 | const context = useSplitContext(); 28 | expect(context).toEqual({ 29 | ...INITIAL_STATUS, 30 | factory: undefined, 31 | client: undefined, 32 | }); 33 | return null; 34 | })} 35 | 36 | ); 37 | }); 38 | 39 | test('passes no-ready properties to the context if initialized with a config.', () => { 40 | render( 41 | 42 | {React.createElement(() => { 43 | const context = useSplitContext(); 44 | expect(context).toEqual({ 45 | ...INITIAL_STATUS, 46 | factory: getLastInstance(SplitFactory), 47 | client: getLastInstance(SplitFactory).client(), 48 | }); 49 | return null; 50 | })} 51 | 52 | ); 53 | }); 54 | 55 | test('passes ready properties to the context if initialized with a ready factory.', async () => { 56 | const outerFactory = SplitFactory(sdkBrowser); 57 | (outerFactory as any).client().__emitter__.emit(Event.SDK_READY_FROM_CACHE); 58 | (outerFactory as any).client().__emitter__.emit(Event.SDK_READY); 59 | (outerFactory.manager().names as jest.Mock).mockReturnValue(['split1']); 60 | await outerFactory.client().ready(); 61 | 62 | render( 63 | 64 | {React.createElement(() => { 65 | const context = useSplitClient(); 66 | 67 | expect(context).toEqual({ 68 | ...INITIAL_STATUS, 69 | factory: outerFactory, 70 | client: outerFactory.client(), 71 | isReady: true, 72 | isReadyFromCache: true, 73 | isOperational: true, 74 | lastUpdate: getStatus(outerFactory.client()).lastUpdate 75 | }); 76 | return null; 77 | })} 78 | 79 | ); 80 | }); 81 | 82 | test('renders a passed JSX.Element with a new SplitContext value.', (done) => { 83 | const Component = () => { 84 | return ( 85 | 86 | {(value) => { 87 | expect(value).toEqual({ 88 | ...INITIAL_STATUS, 89 | factory: getLastInstance(SplitFactory), 90 | client: getLastInstance(SplitFactory).client(), 91 | }); 92 | done(); 93 | return null; 94 | }} 95 | 96 | ); 97 | }; 98 | 99 | render( 100 | 101 | 102 | 103 | ); 104 | }); 105 | 106 | test('logs warning if both a config and factory are passed as props.', () => { 107 | const outerFactory = SplitFactory(sdkBrowser); 108 | 109 | render( 110 | 111 | {React.createElement(() => { 112 | return null; 113 | })} 114 | 115 | ); 116 | 117 | expect(logSpy).toBeCalledWith('[WARN] splitio => ' + WARN_SF_CONFIG_AND_FACTORY); 118 | logSpy.mockRestore(); 119 | }); 120 | 121 | test('cleans up on update and unmount if config prop is provided.', () => { 122 | let renderTimes = 0; 123 | const createdFactories = new Set(); 124 | const factoryDestroySpies: jest.SpyInstance[] = []; 125 | const outerFactory = SplitFactory(sdkBrowser); 126 | 127 | const Component = () => { 128 | const { factory, isReady, hasTimedout } = useSplitClient(); 129 | renderTimes++; 130 | 131 | switch (renderTimes) { 132 | case 1: 133 | expect(factory).toBe(outerFactory); 134 | return null; 135 | case 2: 136 | case 5: 137 | expect(isReady).toBe(false); 138 | expect(hasTimedout).toBe(false); 139 | expect(factory).toBe(getLastInstance(SplitFactory)); 140 | if (!createdFactories.has(factory!)) factoryDestroySpies.push(jest.spyOn(factory!, 'destroy')); 141 | createdFactories.add(factory!); 142 | return null; 143 | case 3: 144 | case 4: 145 | case 6: 146 | expect(isReady).toBe(true); 147 | expect(hasTimedout).toBe(true); 148 | expect(factory).toBe(getLastInstance(SplitFactory)); 149 | if (!createdFactories.has(factory!)) factoryDestroySpies.push(jest.spyOn(factory!, 'destroy')); 150 | createdFactories.add(factory!); 151 | return null; 152 | case 7: 153 | throw new Error('Must not rerender'); 154 | } 155 | return null; 156 | }; 157 | 158 | const emitSdkEvents = () => { 159 | const factory = getLastInstance(SplitFactory); 160 | factory.client().__emitter__.emit(Event.SDK_READY_TIMED_OUT) 161 | factory.client().__emitter__.emit(Event.SDK_READY) 162 | }; 163 | 164 | // 1st render: factory provided 165 | const wrapper = render( 166 | 167 | 168 | 169 | ); 170 | 171 | // 2nd render: factory created, not ready 172 | wrapper.rerender( 173 | 174 | 175 | 176 | ); 177 | 178 | // 3rd render: SDK timeout and ready events emitted (only one re-render due to batched state updates in React) 179 | act(emitSdkEvents); 180 | 181 | // 4th render: same config prop -> factory is not recreated 182 | wrapper.rerender( 183 | 184 | 185 | 186 | ); 187 | 188 | act(emitSdkEvents); // Emitting events again has no effect 189 | expect(createdFactories.size).toBe(1); 190 | 191 | // 5th render: Update config prop -> factory is recreated, not ready yet 192 | wrapper.rerender( 193 | 194 | 195 | 196 | ); 197 | 198 | // 6th render: SDK events emitted 199 | act(emitSdkEvents); 200 | 201 | wrapper.unmount(); 202 | 203 | // factory `destroy` methods are called 204 | expect(createdFactories.size).toBe(2); 205 | expect(factoryDestroySpies.length).toBe(2); 206 | factoryDestroySpies.forEach(spy => expect(spy).toBeCalledTimes(1)); 207 | }); 208 | 209 | test('doesn\'t clean up on unmount if the factory is provided as a prop.', () => { 210 | let destroySpy; 211 | const outerFactory = SplitFactory(sdkBrowser); 212 | const wrapper = render( 213 | 214 | {React.createElement(() => { 215 | const { factory } = useSplitClient(); 216 | destroySpy = jest.spyOn(factory!, 'destroy'); 217 | return null; 218 | })} 219 | 220 | ); 221 | wrapper.unmount(); 222 | expect(destroySpy).not.toBeCalled(); 223 | }); 224 | 225 | test('passes attributes to the main client if provided.', () => { 226 | (SplitFactory as jest.Mock).mockClear(); 227 | let client; 228 | 229 | const Component = () => { 230 | client = useSplitContext().client; 231 | return null; 232 | } 233 | 234 | const wrapper = render( 235 | 236 | 237 | 238 | ); 239 | 240 | expect(client.getAttributes()).toEqual({ attr1: 'value1' }); 241 | 242 | wrapper.rerender( 243 | 244 | 245 | 246 | ); 247 | 248 | expect(client.getAttributes()).toEqual({ attr1: 'value2' }); 249 | expect(SplitFactory).toBeCalledTimes(1); 250 | }); 251 | 252 | }); 253 | -------------------------------------------------------------------------------- /src/__tests__/useTreatmentWithConfig.test.tsx: -------------------------------------------------------------------------------- 1 | import * as React from 'react'; 2 | import { act, render } from '@testing-library/react'; 3 | 4 | /** Mocks */ 5 | import { mockSdk, Event } from './testUtils/mockSplitFactory'; 6 | jest.mock('@splitsoftware/splitio/client', () => { 7 | return { SplitFactory: mockSdk() }; 8 | }); 9 | import { SplitFactory } from '@splitsoftware/splitio/client'; 10 | import { sdkBrowser, sdkBrowserWithConfig } from './testUtils/sdkConfigs'; 11 | import { CONTROL_WITH_CONFIG, EXCEPTION_NO_SFP } from '../constants'; 12 | 13 | /** Test target */ 14 | import { SplitFactoryProvider } from '../SplitFactoryProvider'; 15 | import { useTreatmentWithConfig } from '../useTreatmentWithConfig'; 16 | import { SplitContext } from '../SplitContext'; 17 | import { IUseTreatmentWithConfigResult } from '../types'; 18 | 19 | describe('useTreatmentWithConfig', () => { 20 | 21 | const featureFlagName = 'split1'; 22 | const attributes = { att1: 'att1' }; 23 | const properties = { prop1: 'prop1' }; 24 | 25 | test('returns the treatment evaluated by the main client of the factory at Split context, or control if the client is not operational.', () => { 26 | const outerFactory = SplitFactory(sdkBrowser); 27 | const client: any = outerFactory.client(); 28 | let treatment: SplitIO.TreatmentWithConfig; 29 | 30 | render( 31 | 32 | {React.createElement(() => { 33 | treatment = useTreatmentWithConfig({ name: featureFlagName, attributes, properties }).treatment; 34 | return null; 35 | })} 36 | 37 | ); 38 | 39 | // returns control treatment if not operational (SDK not ready or destroyed), without calling `getTreatmentWithConfig` method 40 | expect(client.getTreatmentWithConfig).not.toBeCalled(); 41 | expect(treatment!).toEqual(CONTROL_WITH_CONFIG); 42 | 43 | // once operational (SDK_READY), it evaluates feature flags 44 | act(() => client.__emitter__.emit(Event.SDK_READY)); 45 | 46 | expect(client.getTreatmentWithConfig).toBeCalledWith(featureFlagName, attributes, { properties }); 47 | expect(client.getTreatmentWithConfig).toHaveReturnedWith(treatment!); 48 | }); 49 | 50 | test('returns the treatments from a new client given a splitKey, and re-evaluates on SDK events.', () => { 51 | const outerFactory = SplitFactory(sdkBrowser); 52 | const client: any = outerFactory.client('user2'); 53 | let renderTimes = 0; 54 | 55 | render( 56 | 57 | {React.createElement(() => { 58 | const treatment = useTreatmentWithConfig({ name: featureFlagName, attributes, properties, splitKey: 'user2', updateOnSdkUpdate: false }).treatment; 59 | 60 | renderTimes++; 61 | switch (renderTimes) { 62 | case 1: 63 | // returns control if not operational (SDK not ready), without calling `getTreatmentWithConfig` method 64 | expect(client.getTreatmentWithConfig).not.toBeCalled(); 65 | expect(treatment).toEqual(CONTROL_WITH_CONFIG); 66 | break; 67 | case 2: 68 | case 3: 69 | // once operational (SDK_READY or SDK_READY_FROM_CACHE), it evaluates feature flags 70 | expect(client.getTreatmentWithConfig).toHaveBeenLastCalledWith(featureFlagName, attributes, { properties }); 71 | expect(client.getTreatmentWithConfig).toHaveLastReturnedWith(treatment); 72 | break; 73 | default: 74 | throw new Error('Unexpected render'); 75 | } 76 | 77 | return null; 78 | })} 79 | 80 | ); 81 | 82 | act(() => client.__emitter__.emit(Event.SDK_READY_FROM_CACHE)); 83 | act(() => client.__emitter__.emit(Event.SDK_READY)); 84 | act(() => client.__emitter__.emit(Event.SDK_UPDATE)); 85 | expect(client.getTreatmentWithConfig).toBeCalledTimes(2); 86 | }); 87 | 88 | test('throws error if invoked outside of SplitFactoryProvider.', () => { 89 | expect(() => { 90 | render( 91 | React.createElement(() => { 92 | useTreatmentWithConfig({ name: featureFlagName, attributes }).treatment; 93 | return null; 94 | }) 95 | ); 96 | }).toThrow(EXCEPTION_NO_SFP); 97 | }); 98 | 99 | test('must update on SDK events', async () => { 100 | const outerFactory = SplitFactory(sdkBrowser); 101 | const mainClient = outerFactory.client() as any; 102 | const user2Client = outerFactory.client('user_2') as any; 103 | 104 | let countSplitContext = 0, countUseTreatmentWithConfig = 0, countUseTreatmentWithConfigUser2 = 0, countUseTreatmentWithConfigUser2WithoutUpdate = 0; 105 | const lastUpdateSetUser2 = new Set(); 106 | const lastUpdateSetUser2WithUpdate = new Set(); 107 | 108 | function validateTreatment({ treatment, isReady, isReadyFromCache }: IUseTreatmentWithConfigResult) { 109 | if (isReady || isReadyFromCache) { 110 | expect(treatment).toEqual({ treatment: 'on', config: null }) 111 | } else { 112 | expect(treatment).toEqual({ treatment: 'control', config: null }) 113 | } 114 | } 115 | 116 | render( 117 | 118 | <> 119 | 120 | {() => countSplitContext++} 121 | 122 | {React.createElement(() => { 123 | const context = useTreatmentWithConfig({ name: 'split_test', attributes: { att1: 'att1' } }); 124 | expect(context.client).toBe(mainClient); // Assert that the main client was retrieved. 125 | validateTreatment(context); 126 | countUseTreatmentWithConfig++; 127 | return null; 128 | })} 129 | {React.createElement(() => { 130 | const context = useTreatmentWithConfig({ name: 'split_test', splitKey: 'user_2' }); 131 | expect(context.client).toBe(user2Client); 132 | validateTreatment(context); 133 | lastUpdateSetUser2.add(context.lastUpdate); 134 | countUseTreatmentWithConfigUser2++; 135 | return null; 136 | })} 137 | {React.createElement(() => { 138 | const context = useTreatmentWithConfig({ name: 'split_test', splitKey: 'user_2', updateOnSdkUpdate: false }); 139 | expect(context.client).toBe(user2Client); 140 | validateTreatment(context); 141 | lastUpdateSetUser2WithUpdate.add(context.lastUpdate); 142 | countUseTreatmentWithConfigUser2WithoutUpdate++; 143 | return null; 144 | })} 145 | 146 | 147 | ); 148 | 149 | act(() => mainClient.__emitter__.emit(Event.SDK_READY_FROM_CACHE)); 150 | act(() => mainClient.__emitter__.emit(Event.SDK_READY)); 151 | act(() => mainClient.__emitter__.emit(Event.SDK_UPDATE)); 152 | act(() => user2Client.__emitter__.emit(Event.SDK_READY_FROM_CACHE)); 153 | act(() => user2Client.__emitter__.emit(Event.SDK_READY)); 154 | act(() => user2Client.__emitter__.emit(Event.SDK_UPDATE)); 155 | 156 | // SplitFactoryProvider renders once 157 | expect(countSplitContext).toEqual(1); 158 | 159 | // If useTreatmentWithConfig evaluates with the main client and have default update options, it re-renders for each main client event. 160 | expect(countUseTreatmentWithConfig).toEqual(4); 161 | expect(mainClient.getTreatmentWithConfig).toHaveBeenCalledTimes(3); // when ready from cache, ready and update 162 | expect(mainClient.getTreatmentWithConfig).toHaveBeenLastCalledWith('split_test', { att1: 'att1' }, undefined); 163 | 164 | // If useTreatmentWithConfig evaluates with a different client and have default update options, it re-renders for each event of the new client. 165 | expect(countUseTreatmentWithConfigUser2).toEqual(4); 166 | expect(lastUpdateSetUser2.size).toEqual(4); 167 | // If it is used with `updateOnSdkUpdate: false`, it doesn't render when the client emits an SDK_UPDATE event. 168 | expect(countUseTreatmentWithConfigUser2WithoutUpdate).toEqual(3); 169 | expect(lastUpdateSetUser2WithUpdate.size).toEqual(3); 170 | expect(user2Client.getTreatmentWithConfig).toHaveBeenCalledTimes(5); // when ready from cache x2, ready x2 and update x1 171 | expect(user2Client.getTreatmentWithConfig).toHaveBeenLastCalledWith('split_test', undefined, undefined); 172 | }); 173 | 174 | test('returns fallback treatment if the client is not operational', () => { 175 | render( 176 | 177 | {React.createElement(() => { 178 | expect(useTreatmentWithConfig({ name: featureFlagName, attributes, properties }).treatment).toEqual({ treatment: 'control_global', config: null }); 179 | expect(useTreatmentWithConfig({ name: 'ff1', attributes, properties }).treatment).toEqual({ treatment: 'control_ff1', config: 'control_ff1_config' }); 180 | return null; 181 | })} 182 | 183 | ); 184 | }); 185 | 186 | }); 187 | -------------------------------------------------------------------------------- /src/__tests__/useTreatments.test.tsx: -------------------------------------------------------------------------------- 1 | import * as React from 'react'; 2 | import { act, render } from '@testing-library/react'; 3 | 4 | /** Mocks */ 5 | import { mockSdk, Event } from './testUtils/mockSplitFactory'; 6 | jest.mock('@splitsoftware/splitio/client', () => { 7 | return { SplitFactory: mockSdk() }; 8 | }); 9 | import { SplitFactory } from '@splitsoftware/splitio/client'; 10 | import { sdkBrowser, sdkBrowserWithConfig } from './testUtils/sdkConfigs'; 11 | import { CONTROL, EXCEPTION_NO_SFP } from '../constants'; 12 | 13 | /** Test target */ 14 | import { SplitFactoryProvider } from '../SplitFactoryProvider'; 15 | import { useTreatments } from '../useTreatments'; 16 | import { SplitContext } from '../SplitContext'; 17 | import { IUseTreatmentsResult } from '../types'; 18 | 19 | describe('useTreatments', () => { 20 | 21 | const featureFlagNames = ['split1']; 22 | const flagSets = ['set1']; 23 | const attributes = { att1: 'att1' }; 24 | const properties = { prop1: 'prop1' }; 25 | 26 | test('returns the treatments evaluated by the main client of the factory at Split context, or control if the client is not operational.', () => { 27 | const outerFactory = SplitFactory(sdkBrowser); 28 | const client: any = outerFactory.client(); 29 | let treatments: SplitIO.Treatments; 30 | let treatmentsByFlagSets: SplitIO.Treatments; 31 | 32 | render( 33 | 34 | {React.createElement(() => { 35 | treatments = useTreatments({ names: featureFlagNames, attributes, properties }).treatments; 36 | treatmentsByFlagSets = useTreatments({ flagSets, attributes, properties }).treatments; 37 | 38 | // @ts-expect-error Options object must provide either names or flagSets 39 | expect(useTreatments({}).treatments).toEqual({}); 40 | return null; 41 | })} 42 | 43 | ); 44 | 45 | // returns control treatment if not operational (SDK not ready or destroyed), without calling `getTreatments` method 46 | expect(client.getTreatments).not.toBeCalled(); 47 | expect(treatments!).toEqual({ split1: CONTROL }); 48 | 49 | // returns empty treatments object if not operational, without calling `getTreatmentsByFlagSets` method 50 | expect(client.getTreatmentsByFlagSets).not.toBeCalled(); 51 | expect(treatmentsByFlagSets!).toEqual({}); 52 | 53 | // once operational (SDK_READY), it evaluates feature flags 54 | act(() => client.__emitter__.emit(Event.SDK_READY)); 55 | 56 | expect(client.getTreatments).toBeCalledWith(featureFlagNames, attributes, { properties }); 57 | expect(client.getTreatments).toHaveReturnedWith(treatments!); 58 | 59 | expect(client.getTreatmentsByFlagSets).toBeCalledWith(flagSets, attributes, { properties }); 60 | expect(client.getTreatmentsByFlagSets).toHaveReturnedWith(treatmentsByFlagSets!); 61 | }); 62 | 63 | test('returns the treatments from a new client given a splitKey, and re-evaluates on SDK events.', () => { 64 | const outerFactory = SplitFactory(sdkBrowser); 65 | const client: any = outerFactory.client('user2'); 66 | let renderTimes = 0; 67 | 68 | render( 69 | 70 | {React.createElement(() => { 71 | const treatments = useTreatments({ names: featureFlagNames, attributes, properties, splitKey: 'user2', updateOnSdkUpdate: false }).treatments; 72 | 73 | renderTimes++; 74 | switch (renderTimes) { 75 | case 1: 76 | // returns control if not operational (SDK not ready), without calling `getTreatments` method 77 | expect(client.getTreatments).not.toBeCalled(); 78 | expect(treatments).toEqual({ split1: CONTROL }); 79 | break; 80 | case 2: 81 | case 3: 82 | // once operational (SDK_READY or SDK_READY_FROM_CACHE), it evaluates feature flags 83 | expect(client.getTreatments).toHaveBeenLastCalledWith(featureFlagNames, attributes, { properties }); 84 | expect(client.getTreatments).toHaveLastReturnedWith(treatments); 85 | break; 86 | default: 87 | throw new Error('Unexpected render'); 88 | } 89 | 90 | return null; 91 | })} 92 | 93 | ); 94 | 95 | act(() => client.__emitter__.emit(Event.SDK_READY_FROM_CACHE)); 96 | act(() => client.__emitter__.emit(Event.SDK_READY)); 97 | act(() => client.__emitter__.emit(Event.SDK_UPDATE)); 98 | expect(client.getTreatments).toBeCalledTimes(2); 99 | }); 100 | 101 | test('throws error if invoked outside of SplitFactoryProvider.', () => { 102 | expect(() => { 103 | render( 104 | React.createElement(() => { 105 | useTreatments({ names: featureFlagNames, attributes }).treatments; 106 | useTreatments({ flagSets: featureFlagNames }).treatments; 107 | return null; 108 | }) 109 | ); 110 | }).toThrow(EXCEPTION_NO_SFP); 111 | }); 112 | 113 | /** 114 | * Input validation: sanitize invalid feature flag names and return control while the SDK is not ready. 115 | */ 116 | test('Input validation: invalid names are sanitized.', () => { 117 | render( 118 | 119 | { 120 | React.createElement(() => { 121 | // @ts-expect-error Test error handling 122 | let treatments = useTreatments('split1').treatments; 123 | expect(treatments).toEqual({}); 124 | // @ts-expect-error Test error handling 125 | treatments = useTreatments({ names: [true, ' flag_1 ', ' '] }).treatments; 126 | expect(treatments).toEqual({ flag_1: CONTROL }); 127 | 128 | return null; 129 | }) 130 | } 131 | 132 | ); 133 | }); 134 | 135 | test('must update on SDK events', async () => { 136 | const outerFactory = SplitFactory(sdkBrowser); 137 | const mainClient = outerFactory.client() as any; 138 | const user2Client = outerFactory.client('user_2') as any; 139 | 140 | let countSplitContext = 0, countUseTreatments = 0, countUseTreatmentsUser2 = 0, countUseTreatmentsUser2WithoutUpdate = 0; 141 | const lastUpdateSetUser2 = new Set(); 142 | const lastUpdateSetUser2WithUpdate = new Set(); 143 | 144 | function validateTreatments({ treatments, isReady, isReadyFromCache }: IUseTreatmentsResult) { 145 | if (isReady || isReadyFromCache) { 146 | expect(treatments).toEqual({ 147 | split_test: 'on' 148 | }) 149 | } else { 150 | expect(treatments).toEqual({ 151 | split_test: 'control' 152 | }) 153 | } 154 | } 155 | 156 | render( 157 | 158 | <> 159 | 160 | {() => countSplitContext++} 161 | 162 | {React.createElement(() => { 163 | const context = useTreatments({ names: ['split_test'], attributes: { att1: 'att1' } }); 164 | expect(context.client).toBe(mainClient); // Assert that the main client was retrieved. 165 | validateTreatments(context); 166 | countUseTreatments++; 167 | return null; 168 | })} 169 | {React.createElement(() => { 170 | const context = useTreatments({ names: ['split_test'], splitKey: 'user_2' }); 171 | expect(context.client).toBe(user2Client); 172 | validateTreatments(context); 173 | lastUpdateSetUser2.add(context.lastUpdate); 174 | countUseTreatmentsUser2++; 175 | return null; 176 | })} 177 | {React.createElement(() => { 178 | const context = useTreatments({ names: ['split_test'], splitKey: 'user_2', updateOnSdkUpdate: false }); 179 | expect(context.client).toBe(user2Client); 180 | validateTreatments(context); 181 | lastUpdateSetUser2WithUpdate.add(context.lastUpdate); 182 | countUseTreatmentsUser2WithoutUpdate++; 183 | return null; 184 | })} 185 | 186 | 187 | ); 188 | 189 | act(() => mainClient.__emitter__.emit(Event.SDK_READY_FROM_CACHE)); 190 | act(() => mainClient.__emitter__.emit(Event.SDK_READY)); 191 | act(() => mainClient.__emitter__.emit(Event.SDK_UPDATE)); 192 | act(() => user2Client.__emitter__.emit(Event.SDK_READY_FROM_CACHE)); 193 | act(() => user2Client.__emitter__.emit(Event.SDK_READY)); 194 | act(() => user2Client.__emitter__.emit(Event.SDK_UPDATE)); 195 | 196 | // SplitFactoryProvider renders once 197 | expect(countSplitContext).toEqual(1); 198 | 199 | // If useTreatments evaluates with the main client and have default update options, it re-renders for each main client event. 200 | expect(countUseTreatments).toEqual(4); 201 | expect(mainClient.getTreatments).toHaveBeenCalledTimes(3); // when ready from cache, ready and update 202 | expect(mainClient.getTreatments).toHaveBeenLastCalledWith(['split_test'], { att1: 'att1' }, undefined); 203 | 204 | // If useTreatments evaluates with a different client and have default update options, it re-renders for each event of the new client. 205 | expect(countUseTreatmentsUser2).toEqual(4); 206 | expect(lastUpdateSetUser2.size).toEqual(4); 207 | // If it is used with `updateOnSdkUpdate: false`, it doesn't render when the client emits an SDK_UPDATE event. 208 | expect(countUseTreatmentsUser2WithoutUpdate).toEqual(3); 209 | expect(lastUpdateSetUser2WithUpdate.size).toEqual(3); 210 | expect(user2Client.getTreatments).toHaveBeenCalledTimes(5); // when ready from cache x2, ready x2 and update x1 211 | expect(user2Client.getTreatments).toHaveBeenLastCalledWith(['split_test'], undefined, undefined); 212 | }); 213 | 214 | test('ignores flagSets if both names and flagSets params are provided.', () => { 215 | render( 216 | 217 | { 218 | React.createElement(() => { 219 | // @ts-expect-error names and flagSets are mutually exclusive 220 | const treatments = useTreatments({ names: featureFlagNames, flagSets, attributes }).treatments; 221 | expect(treatments).toEqual({ split1: CONTROL }); 222 | return null; 223 | }) 224 | } 225 | 226 | ); 227 | }); 228 | 229 | test('returns fallback treatments if the client is not operational', () => { 230 | render( 231 | 232 | {React.createElement(() => { 233 | const { treatments } = useTreatments({ names: ['ff1', 'ff2'], attributes, properties }); 234 | expect(treatments).toEqual({ ff1: 'control_ff1', ff2: 'control_global' }); 235 | return null; 236 | })} 237 | 238 | ); 239 | }); 240 | 241 | }); 242 | -------------------------------------------------------------------------------- /src/__tests__/useTreatmentsWithConfig.test.tsx: -------------------------------------------------------------------------------- 1 | import * as React from 'react'; 2 | import { act, render } from '@testing-library/react'; 3 | 4 | /** Mocks */ 5 | import { mockSdk, Event } from './testUtils/mockSplitFactory'; 6 | jest.mock('@splitsoftware/splitio/client', () => { 7 | return { SplitFactory: mockSdk() }; 8 | }); 9 | import { SplitFactory } from '@splitsoftware/splitio/client'; 10 | import { sdkBrowser, sdkBrowserWithConfig } from './testUtils/sdkConfigs'; 11 | import { CONTROL_WITH_CONFIG, EXCEPTION_NO_SFP } from '../constants'; 12 | 13 | /** Test target */ 14 | import { SplitFactoryProvider } from '../SplitFactoryProvider'; 15 | import { useTreatmentsWithConfig } from '../useTreatmentsWithConfig'; 16 | import { SplitContext } from '../SplitContext'; 17 | import { IUseTreatmentsWithConfigResult } from '../types'; 18 | 19 | describe('useTreatmentsWithConfig', () => { 20 | 21 | const featureFlagNames = ['split1']; 22 | const flagSets = ['set1']; 23 | const attributes = { att1: 'att1' }; 24 | const properties = { prop1: 'prop1' }; 25 | 26 | test('returns the treatments evaluated by the main client of the factory at Split context, or control if the client is not operational.', () => { 27 | const outerFactory = SplitFactory(sdkBrowser); 28 | const client: any = outerFactory.client(); 29 | let treatments: SplitIO.TreatmentsWithConfig; 30 | let treatmentsByFlagSets: SplitIO.TreatmentsWithConfig; 31 | 32 | render( 33 | 34 | {React.createElement(() => { 35 | treatments = useTreatmentsWithConfig({ names: featureFlagNames, attributes, properties }).treatments; 36 | treatmentsByFlagSets = useTreatmentsWithConfig({ flagSets, attributes, properties }).treatments; 37 | 38 | // @ts-expect-error Options object must provide either names or flagSets 39 | expect(useTreatmentsWithConfig({}).treatments).toEqual({}); 40 | return null; 41 | })} 42 | 43 | ); 44 | 45 | // returns control treatment if not operational (SDK not ready or destroyed), without calling `getTreatmentsWithConfig` method 46 | expect(client.getTreatmentsWithConfig).not.toBeCalled(); 47 | expect(treatments!).toEqual({ split1: CONTROL_WITH_CONFIG }); 48 | 49 | // returns empty treatments object if not operational, without calling `getTreatmentsWithConfigByFlagSets` method 50 | expect(client.getTreatmentsWithConfigByFlagSets).not.toBeCalled(); 51 | expect(treatmentsByFlagSets!).toEqual({}); 52 | 53 | // once operational (SDK_READY), it evaluates feature flags 54 | act(() => client.__emitter__.emit(Event.SDK_READY)); 55 | 56 | expect(client.getTreatmentsWithConfig).toBeCalledWith(featureFlagNames, attributes, { properties }); 57 | expect(client.getTreatmentsWithConfig).toHaveReturnedWith(treatments!); 58 | 59 | expect(client.getTreatmentsWithConfigByFlagSets).toBeCalledWith(flagSets, attributes, { properties }); 60 | expect(client.getTreatmentsWithConfigByFlagSets).toHaveReturnedWith(treatmentsByFlagSets!); 61 | }); 62 | 63 | test('returns the treatments from a new client given a splitKey, and re-evaluates on SDK events.', () => { 64 | const outerFactory = SplitFactory(sdkBrowser); 65 | const client: any = outerFactory.client('user2'); 66 | let renderTimes = 0; 67 | 68 | render( 69 | 70 | {React.createElement(() => { 71 | const treatments = useTreatmentsWithConfig({ names: featureFlagNames, attributes, properties, splitKey: 'user2', updateOnSdkUpdate: false }).treatments; 72 | 73 | renderTimes++; 74 | switch (renderTimes) { 75 | case 1: 76 | // returns control if not operational (SDK not ready), without calling `getTreatmentsWithConfig` method 77 | expect(client.getTreatmentsWithConfig).not.toBeCalled(); 78 | expect(treatments).toEqual({ split1: CONTROL_WITH_CONFIG }); 79 | break; 80 | case 2: 81 | case 3: 82 | // once operational (SDK_READY or SDK_READY_FROM_CACHE), it evaluates feature flags 83 | expect(client.getTreatmentsWithConfig).toHaveBeenLastCalledWith(featureFlagNames, attributes, { properties }); 84 | expect(client.getTreatmentsWithConfig).toHaveLastReturnedWith(treatments); 85 | break; 86 | default: 87 | throw new Error('Unexpected render'); 88 | } 89 | 90 | return null; 91 | })} 92 | 93 | ); 94 | 95 | act(() => client.__emitter__.emit(Event.SDK_READY_FROM_CACHE)); 96 | act(() => client.__emitter__.emit(Event.SDK_READY)); 97 | act(() => client.__emitter__.emit(Event.SDK_UPDATE)); 98 | expect(client.getTreatmentsWithConfig).toBeCalledTimes(2); 99 | }); 100 | 101 | test('throws error if invoked outside of SplitFactoryProvider.', () => { 102 | expect(() => { 103 | render( 104 | React.createElement(() => { 105 | useTreatmentsWithConfig({ names: featureFlagNames, attributes }).treatments; 106 | useTreatmentsWithConfig({ flagSets: featureFlagNames }).treatments; 107 | return null; 108 | }) 109 | ); 110 | }).toThrow(EXCEPTION_NO_SFP); 111 | }); 112 | 113 | /** 114 | * Input validation: sanitize invalid feature flag names and return control while the SDK is not ready. 115 | */ 116 | test('Input validation: invalid names are sanitized.', () => { 117 | render( 118 | 119 | { 120 | React.createElement(() => { 121 | // @ts-expect-error Test error handling 122 | let treatments = useTreatmentsWithConfig('split1').treatments; 123 | expect(treatments).toEqual({}); 124 | // @ts-expect-error Test error handling 125 | treatments = useTreatmentsWithConfig({ names: [true, ' flag_1 ', ' '] }).treatments; 126 | expect(treatments).toEqual({ flag_1: CONTROL_WITH_CONFIG }); 127 | 128 | return null; 129 | }) 130 | } 131 | 132 | ); 133 | }); 134 | 135 | test('must update on SDK events', async () => { 136 | const outerFactory = SplitFactory(sdkBrowser); 137 | const mainClient = outerFactory.client() as any; 138 | const user2Client = outerFactory.client('user_2') as any; 139 | 140 | let countSplitContext = 0, countUseTreatmentsWithConfig = 0, countUseTreatmentsWithConfigUser2 = 0, countUseTreatmentsWithConfigUser2WithoutUpdate = 0; 141 | const lastUpdateSetUser2 = new Set(); 142 | const lastUpdateSetUser2WithUpdate = new Set(); 143 | 144 | function validateTreatments({ treatments, isReady, isReadyFromCache }: IUseTreatmentsWithConfigResult) { 145 | if (isReady || isReadyFromCache) { 146 | expect(treatments).toEqual({ 147 | split_test: { 148 | treatment: 'on', 149 | config: null, 150 | } 151 | }) 152 | } else { 153 | expect(treatments).toEqual({ 154 | split_test: { 155 | treatment: 'control', 156 | config: null, 157 | } 158 | }) 159 | } 160 | } 161 | 162 | render( 163 | 164 | <> 165 | 166 | {() => countSplitContext++} 167 | 168 | {React.createElement(() => { 169 | const context = useTreatmentsWithConfig({ names: ['split_test'], attributes: { att1: 'att1' } }); 170 | expect(context.client).toBe(mainClient); // Assert that the main client was retrieved. 171 | validateTreatments(context); 172 | countUseTreatmentsWithConfig++; 173 | return null; 174 | })} 175 | {React.createElement(() => { 176 | const context = useTreatmentsWithConfig({ names: ['split_test'], splitKey: 'user_2' }); 177 | expect(context.client).toBe(user2Client); 178 | validateTreatments(context); 179 | lastUpdateSetUser2.add(context.lastUpdate); 180 | countUseTreatmentsWithConfigUser2++; 181 | return null; 182 | })} 183 | {React.createElement(() => { 184 | const context = useTreatmentsWithConfig({ names: ['split_test'], splitKey: 'user_2', updateOnSdkUpdate: false }); 185 | expect(context.client).toBe(user2Client); 186 | validateTreatments(context); 187 | lastUpdateSetUser2WithUpdate.add(context.lastUpdate); 188 | countUseTreatmentsWithConfigUser2WithoutUpdate++; 189 | return null; 190 | })} 191 | 192 | 193 | ); 194 | 195 | act(() => mainClient.__emitter__.emit(Event.SDK_READY_FROM_CACHE)); 196 | act(() => mainClient.__emitter__.emit(Event.SDK_READY)); 197 | act(() => mainClient.__emitter__.emit(Event.SDK_UPDATE)); 198 | act(() => user2Client.__emitter__.emit(Event.SDK_READY_FROM_CACHE)); 199 | act(() => user2Client.__emitter__.emit(Event.SDK_READY)); 200 | act(() => user2Client.__emitter__.emit(Event.SDK_UPDATE)); 201 | 202 | // SplitFactoryProvider renders once 203 | expect(countSplitContext).toEqual(1); 204 | 205 | // If useTreatmentsWithConfig evaluates with the main client and have default update options, it re-renders for each main client event. 206 | expect(countUseTreatmentsWithConfig).toEqual(4); 207 | expect(mainClient.getTreatmentsWithConfig).toHaveBeenCalledTimes(3); // when ready from cache, ready and update 208 | expect(mainClient.getTreatmentsWithConfig).toHaveBeenLastCalledWith(['split_test'], { att1: 'att1' }, undefined); 209 | 210 | // If useTreatmentsWithConfig evaluates with a different client and have default update options, it re-renders for each event of the new client. 211 | expect(countUseTreatmentsWithConfigUser2).toEqual(4); 212 | expect(lastUpdateSetUser2.size).toEqual(4); 213 | // If it is used with `updateOnSdkUpdate: false`, it doesn't render when the client emits an SDK_UPDATE event. 214 | expect(countUseTreatmentsWithConfigUser2WithoutUpdate).toEqual(3); 215 | expect(lastUpdateSetUser2WithUpdate.size).toEqual(3); 216 | expect(user2Client.getTreatmentsWithConfig).toHaveBeenCalledTimes(5); // when ready from cache x2, ready x2 and update x1 217 | expect(user2Client.getTreatmentsWithConfig).toHaveBeenLastCalledWith(['split_test'], undefined, undefined); 218 | }); 219 | 220 | test('ignores flagSets if both names and flagSets params are provided.', () => { 221 | render( 222 | 223 | { 224 | React.createElement(() => { 225 | // @ts-expect-error names and flagSets are mutually exclusive 226 | const treatments = useTreatmentsWithConfig({ names: featureFlagNames, flagSets, attributes }).treatments; 227 | expect(treatments).toEqual({ split1: CONTROL_WITH_CONFIG }); 228 | return null; 229 | }) 230 | } 231 | 232 | ); 233 | }); 234 | 235 | test('returns fallback treatments if the client is not operational', () => { 236 | render( 237 | 238 | {React.createElement(() => { 239 | const { treatments } = useTreatmentsWithConfig({ names: ['ff1', 'ff2'], attributes, properties }); 240 | expect(treatments).toEqual({ ff1: { treatment: 'control_ff1', config: 'control_ff1_config' }, ff2: { treatment: 'control_global', config: null } }); 241 | return null; 242 | })} 243 | 244 | ); 245 | }); 246 | 247 | }); 248 | -------------------------------------------------------------------------------- /src/types.ts: -------------------------------------------------------------------------------- 1 | import type { ReactNode } from 'react'; 2 | 3 | // @TODO: remove in next major release (it duplicates SplitIO.ReadinessStatus) 4 | /** 5 | * Readiness Status interface. It represents the readiness state of an SDK client. 6 | */ 7 | export interface ISplitStatus extends SplitIO.ReadinessStatus {} 8 | 9 | /** 10 | * Update Props interface. It defines the props used to configure what SDK events are listened to update the component. 11 | */ 12 | export interface IUpdateProps { 13 | 14 | /** 15 | * `updateOnSdkUpdate` indicates if the hook or component will update (i.e., re-render) or not in case of an `SDK_UPDATE` event. 16 | * It's value is `true` by default. 17 | */ 18 | updateOnSdkUpdate?: boolean; 19 | 20 | /** 21 | * `updateOnSdkTimedout` indicates if the hook or component will update (i.e., re-render) or not in case of a `SDK_READY_TIMED_OUT` event. 22 | * It's value is `true` by default. 23 | */ 24 | updateOnSdkTimedout?: boolean; 25 | 26 | /** 27 | * `updateOnSdkReady` indicates if the hook or component will update (i.e., re-render) or not in case of a `SDK_READY` event. 28 | * It's value is `true` by default. 29 | */ 30 | updateOnSdkReady?: boolean; 31 | 32 | /** 33 | * `updateOnSdkReadyFromCache` indicates if the hook or component will update (i.e., re-render) or not in case of a `SDK_READY_FROM_CACHE` event. 34 | * This params is only relevant when using `'LOCALSTORAGE'` as storage type, since otherwise the event is never emitted. 35 | * It's value is `true` by default. 36 | */ 37 | updateOnSdkReadyFromCache?: boolean; 38 | } 39 | 40 | /** 41 | * Split Context Value interface. It is used to define the value types of Split Context 42 | */ 43 | export interface ISplitContextValues extends ISplitStatus, IUpdateProps { 44 | 45 | /** 46 | * Split factory instance. 47 | * 48 | * NOTE: This property is available for accessing factory methods not covered by the library hooks, 49 | * such as Logging configuration and User Consent. 50 | * @see {@link https://developer.harness.io/docs/feature-management-experimentation/sdks-and-infrastructure/client-side-sdks/javascript-sdk/#logging}), 51 | * @see {@link https://developer.harness.io/docs/feature-management-experimentation/sdks-and-infrastructure/client-side-sdks/javascript-sdk/#user-consent} 52 | */ 53 | factory?: SplitIO.IBrowserSDK; 54 | 55 | /** 56 | * Split client instance. 57 | * 58 | * NOTE: This property is not recommended for direct use, as better alternatives are available: 59 | * - `useSplitTreatments` hook to evaluate feature flags. 60 | * - `useTrack` hook to track events. 61 | * 62 | * @see {@link https://developer.harness.io/docs/feature-management-experimentation/sdks-and-infrastructure/client-side-sdks/javascript-sdk/#2-instantiate-the-sdk-and-create-a-new-split-client} 63 | */ 64 | client?: SplitIO.IBrowserClient; 65 | } 66 | 67 | /** 68 | * Props interface for components wrapped by the `withSplitFactory` HOC. These props are provided by the HOC to the wrapped component. 69 | * 70 | * @deprecated `withSplitFactory` will be removed in a future major release. We recommend replacing it with the `SplitFactoryProvider` component. 71 | */ 72 | export interface ISplitFactoryChildProps extends ISplitContextValues { } 73 | 74 | /** 75 | * SplitFactoryProvider Props interface. These are the props accepted by the `SplitFactoryProvider` component, 76 | * used to instantiate a factory and provide it to the Split Context. 77 | */ 78 | export interface ISplitFactoryProviderProps extends IUpdateProps { 79 | 80 | /** 81 | * Config object used to instantiate a Split factory. 82 | * @see {@link https://developer.harness.io/docs/feature-management-experimentation/sdks-and-infrastructure/client-side-sdks/javascript-sdk/#configuration} 83 | */ 84 | config?: SplitIO.IBrowserSettings; 85 | 86 | /** 87 | * Split factory instance to use instead of creating a new one with the `config` object. 88 | * 89 | * If both `factory` and `config` are provided, the `config` prop is ignored. 90 | */ 91 | factory?: SplitIO.IBrowserSDK; 92 | 93 | /** 94 | * An object of type Attributes used to evaluate the feature flags. 95 | */ 96 | attributes?: SplitIO.Attributes; 97 | 98 | /** 99 | * Children of the `SplitFactoryProvider` component. 100 | */ 101 | children: ReactNode; 102 | } 103 | 104 | /** 105 | * Options object accepted by the `useSplitClient` hook, used to retrieve a client instance and update the component on SDK events. 106 | */ 107 | export interface IUseSplitClientOptions extends IUpdateProps { 108 | 109 | /** 110 | * The customer identifier. If not provided, the hook will use the client available in the Split context, which is the default client by default (i.e., `factory.client()`), 111 | * except the hook is wrapped by a `SplitClient` component, in which case the Split context might be updated with a different client. 112 | */ 113 | splitKey?: SplitIO.SplitKey; 114 | 115 | /** 116 | * An object of type Attributes used to evaluate the feature flags. 117 | */ 118 | attributes?: SplitIO.Attributes; 119 | } 120 | 121 | /** 122 | * SplitClient Child Props interface. These are the props that the child as a function receives from the 'SplitClient' component. 123 | */ 124 | export interface ISplitClientChildProps extends ISplitContextValues { } 125 | 126 | /** 127 | * SplitClient Props interface. These are the props accepted by SplitClient component, 128 | * used to instantiate a new client instance, update the Split context, and listen for SDK events. 129 | */ 130 | export interface ISplitClientProps extends IUseSplitClientOptions { 131 | 132 | /** 133 | * Children of the SplitClient component. It can be a functional component (child as a function) or a React element. 134 | */ 135 | children: ((props: ISplitClientChildProps) => ReactNode) | ReactNode; 136 | } 137 | 138 | /** 139 | * Result of the `useSplitManager` hook. 140 | */ 141 | export interface IUseSplitManagerResult extends ISplitContextValues { 142 | /** 143 | * Split manager instance. 144 | * 145 | * @see {@link https://developer.harness.io/docs/feature-management-experimentation/sdks-and-infrastructure/client-side-sdks/javascript-sdk/#manager} 146 | */ 147 | manager?: SplitIO.IManager; 148 | } 149 | 150 | type EvaluationOptions = SplitIO.EvaluationOptions & { 151 | 152 | /** 153 | * An object of type Attributes used to evaluate the feature flags. 154 | */ 155 | attributes?: SplitIO.Attributes; 156 | } 157 | 158 | /** 159 | * @deprecated `useSplitTreatments` will be removed in a future major release. We recommend replacing it with the `useTreatment*` hooks. 160 | */ 161 | export type GetTreatmentsOptions = ({ 162 | 163 | /** 164 | * 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. 165 | */ 166 | names: string[]; 167 | flagSets?: undefined; 168 | } | { 169 | 170 | /** 171 | * 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. 172 | */ 173 | flagSets: string[]; 174 | names?: undefined; 175 | }) & EvaluationOptions; 176 | 177 | /** 178 | * Options object accepted by the `useSplitTreatments` hook, used to call `client.getTreatmentsWithConfig()`, or `client.getTreatmentsWithConfigByFlagSets()`, 179 | * depending on whether `names` or `flagSets` options are provided, and to retrieve the result along with the Split context. 180 | * 181 | * @deprecated `useSplitTreatments` will be removed in a future major release. We recommend replacing it with the `useTreatment*` hooks. 182 | */ 183 | export type IUseSplitTreatmentsOptions = GetTreatmentsOptions & IUseSplitClientOptions; 184 | 185 | /** 186 | * Options object accepted by the `useTreatment` and `useTreatmentWithConfig` hooks. 187 | */ 188 | export type IUseTreatmentOptions = { 189 | 190 | /** 191 | * Feature flag name to evaluate. 192 | */ 193 | name: string; 194 | } & EvaluationOptions & IUseSplitClientOptions; 195 | 196 | 197 | /** 198 | * Options object accepted by the `useTreatments` and `useTreatmentsWithConfig` hooks. 199 | */ 200 | export type IUseTreatmentsOptions = ({ 201 | 202 | /** 203 | * 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. 204 | */ 205 | names: string[]; 206 | flagSets?: undefined; 207 | } | { 208 | 209 | /** 210 | * 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. 211 | */ 212 | flagSets: string[]; 213 | names?: undefined; 214 | }) & EvaluationOptions & IUseSplitClientOptions; 215 | 216 | /** 217 | * SplitTreatments Child Props interface. These are the props that the child component receives from the 'SplitTreatments' component. 218 | * 219 | * @deprecated `SplitTreatments` will be removed in a future major release. We recommend replacing it with the `useTreatments*` hooks. 220 | */ 221 | export interface ISplitTreatmentsChildProps extends ISplitContextValues { 222 | 223 | /** 224 | * An object with the treatments with configs for a bulk of feature flags, returned by client.getTreatmentsWithConfig(). 225 | * Each existing configuration is a stringified version of the JSON you defined on the Split user interface. For example: 226 | * 227 | * ```js 228 | * { 229 | * feature1: { treatment: 'on', config: null }, 230 | * feature2: { treatment: 'off', config: '{"bannerText":"Click here."}' } 231 | * } 232 | * ``` 233 | */ 234 | treatments: SplitIO.TreatmentsWithConfig; 235 | } 236 | 237 | /** 238 | * Result of the `useTreatment` hook. 239 | */ 240 | export interface IUseTreatmentResult extends ISplitContextValues { 241 | /** 242 | * The treatment string for a feature flag, returned by client.getTreatment(). 243 | */ 244 | treatment: SplitIO.Treatment; 245 | } 246 | 247 | /** 248 | * Result of the `useTreatmentWithConfig` hook. 249 | */ 250 | export interface IUseTreatmentWithConfigResult extends ISplitContextValues { 251 | /** 252 | * The treatment with config for a feature flag, returned by client.getTreatmentWithConfig(). 253 | */ 254 | treatment: SplitIO.TreatmentWithConfig; 255 | } 256 | 257 | /** 258 | * Result of the `useTreatments` hook. 259 | */ 260 | export interface IUseTreatmentsResult extends ISplitContextValues { 261 | /** 262 | * An object with the treatment strings for a bulk of feature flags, returned by client.getTreatments() or client.getTreatmentsByFlagSets(). 263 | * For example: 264 | * 265 | * ```js 266 | * { 267 | * feature1: 'on', 268 | * feature2: 'off' 269 | * } 270 | * ``` 271 | */ 272 | treatments: SplitIO.Treatments; 273 | } 274 | 275 | /** 276 | * Result of the `useTreatmentsWithConfig` hook. 277 | */ 278 | export interface IUseTreatmentsWithConfigResult extends ISplitContextValues { 279 | 280 | /** 281 | * An object with the treatments with configs for a bulk of feature flags, returned by client.getTreatmentsWithConfig() or client.getTreatmentsWithConfigByFlagSets(). 282 | * Each existing configuration is a stringified version of the JSON you defined on the Split user interface. For example: 283 | * 284 | * ```js 285 | * { 286 | * feature1: { treatment: 'on', config: null }, 287 | * feature2: { treatment: 'off', config: '{"bannerText":"Click here."}' } 288 | * } 289 | * ``` 290 | */ 291 | treatments: SplitIO.TreatmentsWithConfig; 292 | } 293 | 294 | /** 295 | * SplitTreatments Props interface. These are the props accepted by SplitTreatments component, used to call 'client.getTreatmentsWithConfig()', or 'client.getTreatmentsWithConfigByFlagSets()', 296 | * depending on whether `names` or `flagSets` props are provided, and to pass the result to the child component. 297 | * 298 | * @deprecated `SplitTreatments` will be removed in a future major release. We recommend replacing it with the `useTreatments*` hooks. 299 | */ 300 | export type ISplitTreatmentsProps = IUseSplitTreatmentsOptions & { 301 | 302 | /** 303 | * Children of the SplitTreatments component. It must be a functional component (child as a function) you want to show. 304 | */ 305 | children: ((props: ISplitTreatmentsChildProps) => ReactNode); 306 | } 307 | -------------------------------------------------------------------------------- /src/__tests__/useSplitClient.test.tsx: -------------------------------------------------------------------------------- 1 | import * as React from 'react'; 2 | import { act, fireEvent, render } from '@testing-library/react'; 3 | 4 | /** Mocks */ 5 | import { mockSdk, Event } from './testUtils/mockSplitFactory'; 6 | jest.mock('@splitsoftware/splitio/client', () => { 7 | return { SplitFactory: mockSdk() }; 8 | }); 9 | import { SplitFactory } from '@splitsoftware/splitio/client'; 10 | import { sdkBrowser } from './testUtils/sdkConfigs'; 11 | 12 | /** Test target */ 13 | import { useSplitClient } from '../useSplitClient'; 14 | import { SplitFactoryProvider } from '../SplitFactoryProvider'; 15 | import { SplitContext } from '../SplitContext'; 16 | import { INITIAL_STATUS, testAttributesBinding, TestComponentProps } from './testUtils/utils'; 17 | import { EXCEPTION_NO_SFP } from '../constants'; 18 | 19 | describe('useSplitClient', () => { 20 | 21 | test('returns the main client from the context updated by SplitFactoryProvider.', () => { 22 | const outerFactory = SplitFactory(sdkBrowser); 23 | let client; 24 | render( 25 | 26 | {React.createElement(() => { 27 | client = useSplitClient().client; 28 | return null; 29 | })} 30 | 31 | ); 32 | expect(client).toBe(outerFactory.client()); 33 | }); 34 | 35 | test('returns a new client from the factory at Split context given a splitKey.', () => { 36 | const outerFactory = SplitFactory(sdkBrowser); 37 | let client; 38 | render( 39 | 40 | {React.createElement(() => { 41 | (outerFactory.client as jest.Mock).mockClear(); 42 | client = useSplitClient({ splitKey: 'user2' }).client; 43 | return null; 44 | })} 45 | 46 | ); 47 | expect(outerFactory.client as jest.Mock).toBeCalledWith('user2'); 48 | expect(outerFactory.client as jest.Mock).toHaveReturnedWith(client); 49 | }); 50 | 51 | test('throws error if invoked outside of SplitFactoryProvider.', () => { 52 | expect(() => { 53 | render( 54 | React.createElement(() => { 55 | useSplitClient(); 56 | useSplitClient({ splitKey: 'user2' }); 57 | return null; 58 | }) 59 | ); 60 | }).toThrow(EXCEPTION_NO_SFP); 61 | }); 62 | 63 | test('attributes binding test with utility', (done) => { 64 | 65 | // eslint-disable-next-line react/prop-types 66 | const InnerComponent = ({ splitKey, attributesClient, testSwitch }) => { 67 | useSplitClient({ splitKey, attributes: attributesClient }); 68 | testSwitch(done, splitKey); 69 | return null; 70 | }; 71 | 72 | function Component({ attributesFactory, attributesClient, splitKey, testSwitch, factory }: TestComponentProps) { 73 | return ( 74 | 75 | 76 | 77 | ); 78 | } 79 | 80 | testAttributesBinding(Component); 81 | }); 82 | 83 | test('must update on SDK events', () => { 84 | const outerFactory = SplitFactory(sdkBrowser); 85 | const mainClient = outerFactory.client() as any; 86 | const user2Client = outerFactory.client('user_2') as any; 87 | 88 | let countSplitContext = 0, countUseSplitClient = 0, countUseSplitClientUser2 = 0; 89 | let countUseSplitClientWithoutUpdate = 0, countUseSplitClientUser2WithoutTimeout = 0; 90 | let previousLastUpdate = -1; 91 | 92 | const { getByTestId } = render( 93 | 94 | <> 95 | 96 | {() => countSplitContext++} 97 | 98 | {React.createElement(() => { 99 | // Equivalent to using config key: `const { client } = useSplitClient({ splitKey: sdkBrowser.core.key, attributes: { att1: 'att1' } });` 100 | const { client } = useSplitClient({ attributes: { att1: 'att1' } }); 101 | expect(client).toBe(mainClient); // Assert that the main client was retrieved. 102 | expect(client!.getAttributes()).toEqual({ att1: 'att1' }); // Assert that the client was retrieved with the provided attributes. 103 | countUseSplitClient++; 104 | return null; 105 | })} 106 | {React.createElement(() => { 107 | const { client, isReady, isReadyFromCache, hasTimedout } = useSplitClient({ splitKey: 'user_2', updateOnSdkUpdate: undefined /* default is true */ }); 108 | expect(client).toBe(user2Client); 109 | 110 | countUseSplitClientUser2++; 111 | switch (countUseSplitClientUser2) { 112 | case 1: // initial render 113 | expect([isReady, isReadyFromCache, hasTimedout]).toEqual([false, false, false]); 114 | break; 115 | case 2: // SDK_READY_FROM_CACHE 116 | expect([isReady, isReadyFromCache, hasTimedout]).toEqual([false, true, false]); 117 | break; 118 | case 3: // SDK_READY_TIMED_OUT 119 | expect([isReady, isReadyFromCache, hasTimedout]).toEqual([false, true, true]); 120 | break; 121 | case 4: // SDK_READY 122 | expect([isReady, isReadyFromCache, hasTimedout]).toEqual([true, true, true]); 123 | break; 124 | case 5: // SDK_UPDATE 125 | expect([isReady, isReadyFromCache, hasTimedout]).toEqual([true, true, true]); 126 | break; 127 | default: 128 | throw new Error('Unexpected render'); 129 | } 130 | return null; 131 | })} 132 | {React.createElement(() => { 133 | const [state, setState] = React.useState(false); 134 | 135 | const { isReady, isReadyFromCache, hasTimedout, lastUpdate } = useSplitClient({ splitKey: sdkBrowser.core.key, updateOnSdkUpdate: false }); 136 | countUseSplitClientWithoutUpdate++; 137 | switch (countUseSplitClientWithoutUpdate) { 138 | case 1: // initial render 139 | expect([isReady, isReadyFromCache, hasTimedout]).toEqual([false, false, false]); 140 | expect(lastUpdate).toBe(0); 141 | break; 142 | case 2: // SDK_READY_FROM_CACHE 143 | expect([isReady, isReadyFromCache, hasTimedout]).toEqual([false, true, false]); 144 | expect(lastUpdate).toBeGreaterThan(previousLastUpdate); 145 | break; 146 | case 3: // SDK_READY 147 | expect([isReady, isReadyFromCache, hasTimedout]).toEqual([true, true, false]); 148 | expect(lastUpdate).toBeGreaterThan(previousLastUpdate); 149 | break; 150 | case 4: // Forced re-render, lastUpdate doesn't change after SDK_UPDATE due to updateOnSdkUpdate = false 151 | expect([isReady, isReadyFromCache, hasTimedout]).toEqual([true, true, false]); 152 | expect(lastUpdate).toBe(previousLastUpdate); 153 | break; 154 | default: 155 | throw new Error('Unexpected render'); 156 | } 157 | 158 | previousLastUpdate = lastUpdate; 159 | return ( 160 | 161 | ); 162 | })} 163 | {React.createElement(() => { 164 | useSplitClient({ splitKey: 'user_2', updateOnSdkTimedout: false }); 165 | countUseSplitClientUser2WithoutTimeout++; 166 | return null; 167 | })} 168 | 169 | 170 | ); 171 | 172 | act(() => mainClient.__emitter__.emit(Event.SDK_READY_FROM_CACHE)); 173 | act(() => user2Client.__emitter__.emit(Event.SDK_READY_FROM_CACHE)); 174 | act(() => mainClient.__emitter__.emit(Event.SDK_READY)); 175 | act(() => user2Client.__emitter__.emit(Event.SDK_READY_TIMED_OUT)); 176 | act(() => user2Client.__emitter__.emit(Event.SDK_READY)); 177 | act(() => mainClient.__emitter__.emit(Event.SDK_UPDATE)); 178 | act(() => user2Client.__emitter__.emit(Event.SDK_UPDATE)); 179 | act(() => fireEvent.click(getByTestId('update-button'))); 180 | 181 | // SplitFactoryProvider renders once 182 | expect(countSplitContext).toEqual(1); 183 | 184 | // If useSplitClient retrieves the main client and have default update options, it re-renders for each main client event. 185 | expect(countUseSplitClient).toEqual(4); 186 | 187 | // If useSplitClient retrieves a different client and have default update options, it re-renders for each event of the new client. 188 | expect(countUseSplitClientUser2).toEqual(5); 189 | 190 | // If useSplitClient retrieves the main client and have updateOnSdkUpdate = false, it doesn't render when the main client updates. 191 | expect(countUseSplitClientWithoutUpdate).toEqual(4); 192 | 193 | // If useSplitClient retrieves a different client and have updateOnSdkTimedout = false, it doesn't render when the the new client times out. 194 | expect(countUseSplitClientUser2WithoutTimeout).toEqual(4); 195 | }); 196 | 197 | // Remove this test once side effects are moved to the useSplitClient effect. 198 | test('must update on SDK events between the render phase (hook call) and commit phase (effect call)', () => { 199 | const outerFactory = SplitFactory(sdkBrowser); 200 | let count = 0; 201 | 202 | render( 203 | 204 | {React.createElement(() => { 205 | useSplitClient({ splitKey: 'some_user' }); 206 | count++; 207 | 208 | // side effect in the render phase 209 | const client = outerFactory.client('some_user') as any; 210 | if (!client.getStatus().isReady) client.__emitter__.emit(Event.SDK_READY); 211 | 212 | return null; 213 | })} 214 | 215 | ) 216 | 217 | expect(count).toEqual(2); 218 | }); 219 | 220 | test('must support changes in update props', () => { 221 | const outerFactory = SplitFactory(sdkBrowser); 222 | const mainClient = outerFactory.client() as any; 223 | 224 | let rendersCount = 0; 225 | let currentStatus, previousStatus; 226 | 227 | function InnerComponent(updateOptions) { 228 | previousStatus = currentStatus; 229 | currentStatus = useSplitClient(updateOptions); 230 | rendersCount++; 231 | return null; 232 | } 233 | 234 | function Component(updateOptions) { 235 | return ( 236 | 237 | 238 | 239 | ) 240 | } 241 | 242 | const wrapper = render(); 243 | expect(rendersCount).toBe(1); 244 | 245 | act(() => mainClient.__emitter__.emit(Event.SDK_READY_TIMED_OUT)); // do not trigger re-render because updateOnSdkTimedout is false 246 | expect(rendersCount).toBe(1); 247 | expect(currentStatus).toMatchObject({ ...INITIAL_STATUS, updateOnSdkUpdate: false, updateOnSdkTimedout: false }); 248 | 249 | wrapper.rerender(); 250 | expect(rendersCount).toBe(2); 251 | expect(currentStatus).toEqual(previousStatus); 252 | 253 | wrapper.rerender(); // trigger re-render because there was an SDK_READY_TIMED_OUT event 254 | expect(rendersCount).toBe(4); // @TODO optimize `useSplitClient` to avoid double render 255 | expect(currentStatus).toMatchObject({ isReady: false, isReadyFromCache: false, hasTimedout: true }); 256 | 257 | act(() => mainClient.__emitter__.emit(Event.SDK_READY)); // trigger re-render 258 | expect(rendersCount).toBe(5); 259 | expect(currentStatus).toMatchObject({ isReady: true, isReadyFromCache: true, hasTimedout: true }); 260 | 261 | act(() => mainClient.__emitter__.emit(Event.SDK_UPDATE)); // do not trigger re-render because updateOnSdkUpdate is false 262 | expect(rendersCount).toBe(5); 263 | 264 | wrapper.rerender(); // should not update the status (SDK_UPDATE event should be ignored) 265 | expect(rendersCount).toBe(6); 266 | expect(currentStatus).toEqual({ ...previousStatus, updateOnSdkTimedout: false }); 267 | 268 | wrapper.rerender(); // trigger re-render and update the status because updateOnSdkUpdate is true and there was an SDK_UPDATE event 269 | expect(rendersCount).toBe(8); // @TODO optimize `useSplitClient` to avoid double render 270 | expect(currentStatus.lastUpdate).toBeGreaterThan(previousStatus.lastUpdate); 271 | 272 | act(() => mainClient.__emitter__.emit(Event.SDK_UPDATE)); // trigger re-render because updateOnSdkUpdate is true 273 | expect(rendersCount).toBe(9); 274 | expect(currentStatus.lastUpdate).toBeGreaterThan(previousStatus.lastUpdate); 275 | 276 | wrapper.rerender(); 277 | expect(rendersCount).toBe(10); 278 | expect(currentStatus).toEqual({ ...previousStatus, updateOnSdkUpdate: false }); 279 | 280 | act(() => mainClient.__emitter__.emit(Event.SDK_UPDATE)); // do not trigger re-render because updateOnSdkUpdate is false now 281 | expect(rendersCount).toBe(10); 282 | }); 283 | 284 | test('must prioritize explicitly provided `updateOn` options over context defaults', () => { 285 | render( 286 | 287 | {React.createElement(() => { 288 | expect(useSplitClient()).toEqual({ 289 | ...INITIAL_STATUS, 290 | updateOnSdkReadyFromCache: false 291 | }); 292 | 293 | expect(useSplitClient({ updateOnSdkReady: false, updateOnSdkReadyFromCache: undefined, updateOnSdkTimedout: false })).toEqual({ 294 | ...INITIAL_STATUS, 295 | updateOnSdkReady: false, 296 | updateOnSdkReadyFromCache: false, 297 | updateOnSdkTimedout: false 298 | }); 299 | return null; 300 | })} 301 | 302 | ); 303 | }); 304 | 305 | }); 306 | -------------------------------------------------------------------------------- /MIGRATION-GUIDE.md: -------------------------------------------------------------------------------- 1 | 2 | # Migrating to React SDK v2.0.0 3 | 4 | React SDK v2.0.0 has a few breaking changes that you should consider when migrating from a previous version. The main changes are: 5 | 6 | 7 | ### • `useTreatments` hook was removed in v2.0.0, but re-introduced in v2.6.0 with a different API: 8 | 9 | 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: 10 | 11 | - `useTreatment`: returns a treatment value for a given feature flag name. It calls `client.getTreatment()` method under the hood. 12 | - `useTreatmentWithConfig`: returns a treatment value and its configuration for a given feature flag name. It calls `client.getTreatmentWithConfig()` method under the hood. 13 | - `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. 14 | - `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. 15 | 16 | The `useTreatments` hook from v1.x.x should be replaced with `useTreatmentsWithConfig`, as follows: 17 | 18 | ```javascript 19 | // v1.x.x 20 | const treatments = useTreatments(featureFlagNames, optionalAttributes, optionalSplitKey); 21 | 22 | // v2.6.0+ 23 | const { treatments } = useTreatmentsWithConfig({ names: featureFlagNames, attributes: optionalAttributes, splitKey: optionalSplitKey }); 24 | 25 | // v2.0.0-v2.5.0 26 | const { treatments } = useSplitTreatments({ names: featureFlagNames, attributes: optionalAttributes, splitKey: optionalSplitKey }); 27 | ``` 28 | 29 | ### • Deprecated `useClient` and `useManager` hooks have been removed. 30 | 31 | 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`. 32 | 33 | ### • Updated the default value of `updateOnSdkUpdate` and `updateOnSdkTimedout` options to `true`. 34 | 35 | Since v2.0.0, the **default values** of the `updateOnSdkUpdate` and `updateOnSdkTimedout` options of the `useSplitClient` and `useSplitTreatments` hooks were changed from `false` to `true`, meaning that the components using them will update by default when an `SDK_UPDATE` or `SDK_READY_TIMED_OUT` event is emitted. 36 | 37 | Consider setting the `updateOnSdkUpdate` option to `false` to revert to the previous behavior if you want to avoid re-renders and re-evaluations of treatments when feature flags are updated by the SDK in background. 38 | 39 | 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). 40 | 41 | ### • Deprecated `SplitFactory` provider has been removed, `withSplitFactory` is deprecated, and `SplitFactoryProvider` doesn't accept a render function as children anymore. 42 | 43 | To migrate your existing code to the new version of `SplitFactoryProvider`, consider the following refactor example: 44 | 45 | ```tsx 46 | const MyComponent = (props: ISplitContextValues) => { 47 | const { factory, client, isReady, isReadyFromCache, ... } = props; 48 | ... 49 | }; 50 | 51 | // if using SplitFactoryProvider v1.11.0 52 | const App = () => { 53 | return ( 54 | 55 | {MyComponent} 56 | 57 | ); 58 | }; 59 | 60 | // or SplitFactory 61 | const App = () => { 62 | return ( 63 | 64 | {MyComponent} 65 | 66 | ); 67 | }; 68 | 69 | // or withSplitFactory 70 | const App = withSplitFactory(mySplitConfig, undefined, DEFAULT_CLIENT_ATTRIBUTES)( 71 | MyComponent, false /* updateOnSdkUpdate = false */ 72 | ); 73 | ``` 74 | 75 | should be refactored to: 76 | 77 | ```tsx 78 | const MyComponent = () => { 79 | const props: ISplitContextValues = useSplitClient(); 80 | const { factory, client, isReady, isReadyFromCache, ... } = props; 81 | ... 82 | }; 83 | 84 | const App = () => { 85 | return ( 86 | 87 | 88 | 89 | ); 90 | }; 91 | ``` 92 | 93 | 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. 94 | 95 | ### • 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. 96 | 97 | The deprecation is intended to simplify the API and discourage using old patterns (HOCs and render props) in favor of the *hook* alternatives, to take advantage of React optimizations. 98 | 99 | To migrate your existing code based on `withSplitClient` or `SplitClient`, consider the following refactor using the `useSplitClient` hook: 100 | 101 | ```tsx 102 | const MyComponent = (props: ISplitContextValues) => { 103 | const { client, isReady, ... } = props; 104 | ... 105 | }; 106 | 107 | const App = withSplitFactory(mySplitConfig)( 108 | withSplitClient(OTHER_KEY, OTHER_KEY_ATTRIBUTES)( 109 | MyComponent, undefined, undefined, undefined, false /* updateOnSdkReadyFromCache = false */ 110 | ) 111 | ); 112 | 113 | // or 114 | const App = () => { 115 | return ( 116 | 117 | 118 | {MyComponent} 119 | 120 | 121 | ) 122 | }; 123 | ``` 124 | 125 | should be refactored to: 126 | 127 | ```tsx 128 | const MyComponent = () => { 129 | const props: ISplitContextValues = useSplitClient({ splitKey: OTHER_KEY, attributes: OTHER_KEY_ATTRIBUTES, updateOnSdkReadyFromCache: false }); 130 | const { client, isReady, ... } = props; 131 | ... 132 | }; 133 | 134 | const App = () => { 135 | return ( 136 | 137 | 138 | 139 | ) 140 | }; 141 | ``` 142 | 143 | To migrate your existing code based on `withSplitTreatments` or `SplitTreatments`, consider the following refactor using the `useSplitTreatments` hook: 144 | 145 | ```tsx 146 | const MyComponent = (props: ISplitTreatmentsChildProps) => { 147 | const { treatments, isReady, ... } = props; 148 | ... 149 | }; 150 | 151 | const App = withSplitFactory(mySplitConfig)( 152 | withSplitClient(OTHER_KEY)( 153 | withSplitTreatments(FEATURE_FLAG_NAMES, ATTRIBUTES)( 154 | MyComponent 155 | ) 156 | ) 157 | ); 158 | 159 | // or 160 | const App = () => { 161 | return ( 162 | 163 | 164 | 165 | {MyComponent} 166 | 167 | 168 | 169 | ) 170 | }; 171 | ``` 172 | 173 | should be refactored to: 174 | 175 | ```tsx 176 | const MyComponent = () => { 177 | const props: ISplitTreatmentsChildProps = useSplitTreatments({ splitKey: OTHER_KEY, names: FEATURE_FLAG_NAMES, attributes: ATTRIBUTES }); 178 | const { treatments, isReady, ... } = props; 179 | ... 180 | }; 181 | 182 | const App = () => { 183 | return ( 184 | 185 | 186 | 187 | ) 188 | }; 189 | ``` 190 | 191 | ### • Renamed `SplitSdk` function to `SplitFactory`. 192 | 193 | If you are using the `SplitSdk` function to create a factory and pass it to the `SplitFactoryProvider` component, you should rename it to `SplitFactory`. For example: 194 | 195 | ```tsx 196 | import { SplitSdk, SplitFactoryProvider } from '@splitsoftware/splitio-react'; 197 | 198 | const myFactory = SplitSdk(mySplitConfig); 199 | 200 | const App = () => { 201 | return ( 202 | 203 | 204 | 205 | ); 206 | }; 207 | ``` 208 | 209 | should be refactored to: 210 | 211 | ```tsx 212 | import { SplitFactory, SplitFactoryProvider } from '@splitsoftware/splitio-react'; 213 | 214 | const myFactory = SplitFactory(mySplitConfig); 215 | 216 | const App = () => { 217 | return ( 218 | 219 | 220 | 221 | ); 222 | }; 223 | ``` 224 | 225 | ### • Traffic type cannot be bound to SDK clients anymore. 226 | 227 | If you were passing the `trafficType` to the SDK config, `useSplitClient` hook, or `useTrack` hook, you should remove it. The `trafficType` must now be passed as the first argument of the `track` method. For example: 228 | 229 | ```tsx 230 | const mySplitConfig = { 231 | core: { 232 | authorizationKey: YOUR_CLIENT_SIDE_SDK_KEY, 233 | key: USER_KEY, 234 | trafficType: 'user' 235 | } 236 | } 237 | 238 | const MyComponent = () => { 239 | const track = useTrack(); 240 | const accountTrack = useTrack(ACCOUNT_KEY, 'account'); 241 | 242 | useEffect(() => { 243 | track('my_event'); 244 | accountTrack('my_event'); 245 | }, []); 246 | 247 | ... 248 | }; 249 | 250 | const App = () => { 251 | return ( 252 | 253 | 254 | 255 | ) 256 | }; 257 | ``` 258 | 259 | should be refactored to: 260 | 261 | ```tsx 262 | const mySplitConfig = { 263 | core: { 264 | authorizationKey: YOUR_CLIENT_SIDE_SDK_KEY, 265 | key: USER_KEY 266 | } 267 | } 268 | 269 | const MyComponent = () => { 270 | const track = useTrack(); 271 | const accountTrack = useTrack(ACCOUNT_KEY); 272 | 273 | useEffect(() => { 274 | track('user', 'my_event'); 275 | accountTrack('account', 'my_event'); 276 | }, []); 277 | ... 278 | }; 279 | 280 | const App = () => { 281 | return ( 282 | 283 | 284 | 285 | ) 286 | }; 287 | ``` 288 | 289 | # Migrating to get React SDK v1.11.0 improvements: Replacing the deprecated `SplitFactory` and `withSplitFactory` components 290 | 291 | Starting from React SDK v1.11.0, the `SplitFactoryProvider` component is available and can replace the older `SplitFactory` and `withSplitFactory` components. The deprecated components will continue working, until they are removed in a future major release. 292 | 293 | We recommend migrating to the new `SplitFactoryProvider` component instead. This component is a revised version of `SplitFactory` that properly handles SDK side effects (i.e., factory creation and destruction) within the React component lifecycle. By migrating, you can benefit from a number of improvements: 294 | 295 | - Resolution of memory leak issues in React development mode, strict mode, and server-side rendering. 296 | 297 | - Updating the SDK when `config` or `factory` props change. 298 | 299 | Notable changes to consider when migrating: 300 | - `SplitFactoryProvider` utilizes the React Hooks API, requiring React 16.8.0 or later, while `SplitFactory` is compatible with React 16.3.0 or later. 301 | 302 | - When using the `config` prop with `SplitFactoryProvider`, the `factory` and `client` properties in `SplitContext` and the `manager` property in `useSplitManager` results are `null` in the first render, until the context is updated when some event is emitted on the SDK main client (ready, ready from cache, timeout, or update, depending on the configuration of the `updateOn` props of the component). This differs from the previous behavior where `factory`, `client`, and `manager` were immediately available. Nonetheless, it is not recommended to use the `client` and `factory` properties directly as better alternatives are available. For example, use the `useTrack` and `useSplitTreatments` hooks rather than the client's `track` and `getTreatments` methods. 303 | 304 | - Updating the `config` prop in `SplitFactoryProvider` re-initializes the SDK with the new configuration, while `SplitFactory` does not reinitialize the SDK. You should pass a reference to the configuration object (e.g., via a global variable, `useState`, or `useMemo`) rather than a new instance on each render, to avoid unnecessary re-initializations. 305 | 306 | - Updating the `factory` prop in `SplitFactoryProvider` replaces the current SDK instance, unlike `SplitFactory` where it is ignored. 307 | 308 | To migrate your existing code, replace: 309 | 310 | ```javascript 311 | const MyApp = () => { 312 | return ( 313 | 314 | 315 | 316 | ); 317 | }; 318 | ``` 319 | 320 | or 321 | 322 | ```javascript 323 | const MyApp = withSplitFactory(mySplitConfig)(MyComponent); 324 | ``` 325 | 326 | with: 327 | 328 | ```javascript 329 | const MyApp = () => { 330 | return ( 331 | 332 | 333 | 334 | ); 335 | }; 336 | ``` 337 | 338 | and consider that `factory`, `client` and `manager` properties might be `null` until the SDK has emitted some event: 339 | 340 | ```javascript 341 | const MyComponent = () => { 342 | // factoryFromContext === factory, clientFromContext === client, and they are null until some SDK event is emitted 343 | const { factory: factoryFromContext, client: clientFromContext } = useContext(SplitContext); 344 | const { factory, client } = useSplitClient(); 345 | 346 | // Example to evaluate all your flags when the SDK is ready and re-evaluate on SDK_UPDATE events 347 | const { manager } = useSplitManager(); 348 | const FEATURE_FLAG_NAMES = manager ? manager.names() : []; 349 | const { treatments, isReady } = useSplitTreatments({ names: FEATURE_FLAG_NAMES, updateOnSdkUpdate: true }); // updateOnSdkReady is true by default 350 | 351 | return isReady ? 352 | treatments['feature-flag-1'].treatment === 'on' ? 353 | : 354 | : 355 | 356 | } 357 | ``` 358 | 359 | # Migrating to get React SDK v1.10.0 improvements: Replacing the deprecated `useClient`, `useTreatments`, and `useManager` hooks 360 | 361 | Starting from React SDK v1.10.0, the `useSplitClient`, `useSplitTreatments`, and `useSplitManager` hooks are available and can replace the older `useClient`, `useTreatments`, and `useManager` hooks respectively. The deprecated hooks will continue working, until they are removed in a future major release. 362 | 363 | We recommend migrating to the new versions `useSplitClient`, `useSplitTreatments` and `useSplitManager` respectively, which provide a more flexible API: 364 | 365 | - They accept an options object as parameter, instead of a list of parameters as their deprecated counterparts. The options object can contain the same parameters as the old hooks, plus some extra optional parameters: `updateOnSdkReady`, `updateOnSdkReadyFromCache`, `updateOnSdkTimedout`, and `updateOnSdkUpdate`, which control when the hook updates the component. For example, you can set `updateOnSdkUpdate` to `true`, which is `false` by default, to update the component when an `SDK_UPDATE` event is emitted. This is useful when you want to avoid unnecessary re-renders of your components. 366 | 367 | - They return an object containing the SDK status properties. These properties are described in the ['Subscribe to events and changes' section](https://developer.harness.io/docs/feature-management-experimentation/sdks-and-infrastructure/client-side-sdks/react-sdk/#subscribe-to-events-and-changes) and enable conditional rendering of components based on the SDK status, eliminating the need to access the Split context or use the client's `ready` promise or event listeners. For example, you can show a loading spinner until the SDK is ready, and use the `treatments` result to render the variants of your app once the SDK is ready. 368 | 369 | The usage of the new hooks is shown below: 370 | 371 | ```js 372 | const { client, isReady, isReadyFromCache, hasTimedout, lastUpdate } = useSplitClient({ splitKey: userId, updateOnSdkUpdate: true }); 373 | const { treatments, isReady, isReadyFromCache, hasTimedout, lastUpdate } = useSplitTreatments({ names: ['feature-flag-1'], updateOnSdkTimedout: false }); 374 | const { manager, isReady, isReadyFromCache, hasTimedout, lastUpdate } = useSplitManager(); 375 | ``` 376 | 377 | 378 | 379 | To migrate your existing code, replace: 380 | 381 | ```javascript 382 | const client = useClient(optionalSplitKey, optionalTrafficType, optionalAttributes); 383 | const treatments = useTreatments(featureFlagNames, optionalAttributes, optionalSplitKey); 384 | const manager = useManager(); 385 | ``` 386 | 387 | with: 388 | 389 | ```javascript 390 | const { client } = useSplitClient({ splitKey: optionalSplitKey, trafficType: optionalTrafficType, attributes: optionalAttributes }); 391 | const { treatments } = useSplitTreatments({ names: featureFlagNames, attributes: optionalAttributes, splitKey: optionalSplitKey }); 392 | const { manager } = useSplitManager(); 393 | ``` 394 | 395 | and use the status properties to conditionally render your components. For example, use the following code: 396 | 397 | ```javascript 398 | const MyComponent = ({ userId }) => { 399 | 400 | const { treatments, isReady } = useSplitTreatments({ names: [FEATURE_X], splitKey: userId }) 401 | 402 | return isReady ? 403 | treatments[FEATURE_X].treatment === 'on' ? 404 | : 405 | : 406 | 407 | } 408 | ``` 409 | 410 | instead of: 411 | 412 | ```javascript 413 | const MyComponent = ({ userId }) => { 414 | 415 | const [sdkIsReady, setSdkIsReady] = useState(false); 416 | const client = useClient(userId); 417 | const treatments = useTreatments([FEATURE_X], undefined, userId); 418 | 419 | useEffect(() => { 420 | if (client) client.ready().then(() => setSdkIsReady(true)); 421 | }, [client]); 422 | 423 | return isReady ? 424 | treatments[FEATURE_X].treatment === 'on' ? 425 | : 426 | : 427 | 428 | } 429 | ``` 430 | -------------------------------------------------------------------------------- /src/__tests__/SplitTreatments.test.tsx: -------------------------------------------------------------------------------- 1 | import * as React from 'react'; 2 | import { render, RenderResult, act } from '@testing-library/react'; 3 | 4 | /** Mocks */ 5 | import { mockSdk, Event } from './testUtils/mockSplitFactory'; 6 | jest.mock('@splitsoftware/splitio/client', () => { 7 | return { SplitFactory: mockSdk() }; 8 | }); 9 | import { SplitFactory } from '@splitsoftware/splitio/client'; 10 | import { sdkBrowser } from './testUtils/sdkConfigs'; 11 | import { getStatus } from '../utils'; 12 | import { newSplitFactoryLocalhostInstance } from './testUtils/utils'; 13 | import { CONTROL_WITH_CONFIG, EXCEPTION_NO_SFP } from '../constants'; 14 | 15 | /** Test target */ 16 | import { ISplitTreatmentsChildProps, ISplitTreatmentsProps, ISplitClientProps } from '../types'; 17 | import { SplitTreatments } from '../SplitTreatments'; 18 | import { SplitClient } from '../SplitClient'; 19 | import { SplitFactoryProvider } from '../SplitFactoryProvider'; 20 | import { useSplitTreatments } from '../useSplitTreatments'; 21 | 22 | describe('SplitTreatments', () => { 23 | 24 | const featureFlagNames = ['split1', 'split2']; 25 | const flagSets = ['set1', 'set2']; 26 | 27 | it('passes control treatments (empty object if flagSets is provided) if the SDK is not operational.', () => { 28 | render( 29 | 30 | 31 | {() => { 32 | return ( 33 |
34 | 35 | {({ treatments }: ISplitTreatmentsChildProps) => { 36 | expect(treatments).toEqual({ split1: CONTROL_WITH_CONFIG, split2: CONTROL_WITH_CONFIG }); 37 | return null; 38 | }} 39 | 40 | 41 | {({ treatments }: ISplitTreatmentsChildProps) => { 42 | expect(treatments).toEqual({}); 43 | return null; 44 | }} 45 | 46 |
47 | ); 48 | }} 49 |
50 |
51 | ); 52 | }); 53 | 54 | it('passes as treatments prop the value returned by the method "client.getTreatmentsWithConfig(ByFlagSets)" if the SDK is ready.', () => { 55 | const outerFactory = SplitFactory(sdkBrowser); 56 | (outerFactory as any).client().__emitter__.emit(Event.SDK_READY); 57 | 58 | render( 59 | 60 | 61 | {({ factory, isReady }) => { 62 | expect(getStatus(outerFactory.client()).isReady).toBe(isReady); 63 | expect(isReady).toBe(true); 64 | return ( 65 | <> 66 | 67 | {({ treatments, isReady: isReady2, isReadyFromCache, hasTimedout, isTimedout, isDestroyed, lastUpdate }: ISplitTreatmentsChildProps) => { 68 | const clientMock: any = factory?.client(); 69 | expect(clientMock.getTreatmentsWithConfig.mock.calls.length).toBe(1); 70 | expect(treatments).toBe(clientMock.getTreatmentsWithConfig.mock.results[0].value); 71 | expect(featureFlagNames).toBe(clientMock.getTreatmentsWithConfig.mock.calls[0][0]); 72 | expect([isReady2, isReadyFromCache, hasTimedout, isTimedout, isDestroyed, lastUpdate]).toStrictEqual([true, true, false, false, false, getStatus(outerFactory.client()).lastUpdate]); 73 | return null; 74 | }} 75 | 76 | 77 | {({ treatments }: ISplitTreatmentsChildProps) => { 78 | const clientMock: any = factory?.client(); 79 | expect(clientMock.getTreatmentsWithConfigByFlagSets.mock.calls.length).toBe(1); 80 | expect(treatments).toBe(clientMock.getTreatmentsWithConfigByFlagSets.mock.results[0].value); 81 | expect(flagSets).toBe(clientMock.getTreatmentsWithConfigByFlagSets.mock.calls[0][0]); 82 | return null; 83 | }} 84 | 85 | 86 | ); 87 | }} 88 | 89 | 90 | ); 91 | }); 92 | 93 | test('throws error if invoked outside of SplitFactoryProvider.', () => { 94 | expect(() => { 95 | render( 96 | 97 | {() => null} 98 | 99 | ); 100 | }).toThrow(EXCEPTION_NO_SFP); 101 | }); 102 | 103 | /** 104 | * Input validation: sanitize invalid feature flag names and return control while the SDK is not ready. 105 | */ 106 | it('Input validation: invalid names are sanitized.', () => { 107 | render( 108 | 109 | 110 | {() => { 111 | return ( 112 | <> 113 | {/* @ts-expect-error Test error handling */} 114 | 115 | {({ treatments }: ISplitTreatmentsChildProps) => { 116 | expect(treatments).toEqual({}); 117 | return null; 118 | }} 119 | 120 | {/* @ts-expect-error Test error handling */} 121 | 122 | {({ treatments }: ISplitTreatmentsChildProps) => { 123 | expect(treatments).toEqual({}); 124 | return null; 125 | }} 126 | 127 | {/* @ts-expect-error Test error handling */} 128 | 129 | {({ treatments }: ISplitTreatmentsChildProps) => { 130 | expect(treatments).toEqual({ flag_1: CONTROL_WITH_CONFIG }); 131 | return null; 132 | }} 133 | 134 | 135 | ); 136 | }} 137 | 138 | 139 | ); 140 | }); 141 | 142 | test('ignores flagSets if both names and flagSets params are provided.', () => { 143 | render( 144 | 145 | {/* @ts-expect-error flagSets and names are mutually exclusive */} 146 | 147 | {({ treatments }) => { 148 | expect(treatments).toEqual({ split1: CONTROL_WITH_CONFIG, split2: CONTROL_WITH_CONFIG }); 149 | return null; 150 | }} 151 | 152 | 153 | ); 154 | }); 155 | 156 | test('returns the treatments from the client at Split context updated by SplitClient, or control if the client is not operational.', async () => { 157 | const outerFactory = SplitFactory(sdkBrowser); 158 | const client: any = outerFactory.client('user2'); 159 | const attributes = { att1: 'att1' }; 160 | let treatments: SplitIO.TreatmentsWithConfig; 161 | 162 | render( 163 | 164 | 165 | {React.createElement(() => { 166 | treatments = useSplitTreatments({ names: featureFlagNames, attributes }).treatments; 167 | return null; 168 | })} 169 | 170 | 171 | ); 172 | 173 | // returns control treatment if not operational (SDK not ready or destroyed), without calling `getTreatmentsWithConfig` method 174 | expect(client.getTreatmentsWithConfig).not.toBeCalled(); 175 | expect(treatments!).toEqual({ split1: CONTROL_WITH_CONFIG, split2: CONTROL_WITH_CONFIG }); 176 | 177 | // once operational (SDK_READY_FROM_CACHE), it evaluates feature flags 178 | act(() => client.__emitter__.emit(Event.SDK_READY_FROM_CACHE)); 179 | 180 | expect(client.getTreatmentsWithConfig).toBeCalledWith(featureFlagNames, attributes, undefined); 181 | expect(client.getTreatmentsWithConfig).toHaveReturnedWith(treatments!); 182 | }); 183 | }); 184 | 185 | let renderTimes = 0; 186 | 187 | /** 188 | * Tests for asserting that client.getTreatmentsWithConfig and client.getTreatmentsWithConfigByFlagSets are not called unnecessarily when using SplitTreatments and useSplitTreatments. 189 | */ 190 | describe.each([ 191 | ({ names, flagSets, attributes, updateOnSdkUpdate }: { names?: string[], flagSets?: string[], attributes?: SplitIO.Attributes, updateOnSdkUpdate?: boolean }) => ( 192 | // @ts-expect-error names and flagSets are mutually exclusive 193 | 194 | {() => { 195 | renderTimes++; 196 | return null; 197 | }} 198 | 199 | ), 200 | ({ names, flagSets, attributes, updateOnSdkUpdate }: { names?: string[], flagSets?: string[], attributes?: SplitIO.Attributes, updateOnSdkUpdate?: boolean }) => { 201 | // @ts-expect-error names and flagSets are mutually exclusive 202 | useSplitTreatments({ names, flagSets, attributes, updateOnSdkUpdate }); 203 | renderTimes++; 204 | return null; 205 | } 206 | ])('SplitTreatments & useSplitTreatments optimization', (InnerComponent) => { 207 | let outerFactory = SplitFactory(sdkBrowser); 208 | (outerFactory as any).client().__emitter__.emit(Event.SDK_READY); 209 | 210 | function Component({ names, flagSets, attributes, splitKey, clientAttributes, updateOnSdkUpdate }: { 211 | names?: ISplitTreatmentsProps['names'] 212 | flagSets?: ISplitTreatmentsProps['flagSets'] 213 | attributes: ISplitTreatmentsProps['attributes'] 214 | splitKey: ISplitClientProps['splitKey'] 215 | clientAttributes?: ISplitClientProps['attributes'], 216 | updateOnSdkUpdate?: boolean 217 | }) { 218 | return ( 219 | 220 | 221 | 222 | 223 | 224 | ); 225 | } 226 | 227 | const names = ['split1', 'split2']; 228 | const flagSets = ['set1', 'set2']; 229 | const attributes = { att1: 'att1' }; 230 | const splitKey = sdkBrowser.core.key; 231 | 232 | let wrapper: RenderResult; 233 | 234 | beforeEach(() => { 235 | renderTimes = 0; 236 | (outerFactory.client().getTreatmentsWithConfig as jest.Mock).mockClear(); 237 | wrapper = render(); 238 | }) 239 | 240 | afterEach(() => { 241 | wrapper.unmount(); // unmount to remove event listener from factory 242 | }) 243 | 244 | it('rerenders but does not re-evaluate feature flags if client, lastUpdate, names, flagSets and attributes are the same object.', () => { 245 | wrapper.rerender(); 246 | 247 | expect(renderTimes).toBe(2); 248 | expect(outerFactory.client().getTreatmentsWithConfig).toBeCalledTimes(1); 249 | expect(outerFactory.client().getTreatmentsWithConfigByFlagSets).toBeCalledTimes(0); 250 | }); 251 | 252 | it('rerenders but does not re-evaluate feature flags if client, lastUpdate, names, flagSets and attributes are equals (shallow comparison).', () => { 253 | wrapper.rerender(); 254 | 255 | expect(renderTimes).toBe(2); 256 | expect(outerFactory.client().getTreatmentsWithConfig).toBeCalledTimes(1); 257 | expect(outerFactory.client().getTreatmentsWithConfigByFlagSets).toBeCalledTimes(0); 258 | }); 259 | 260 | it('rerenders and re-evaluates feature flags if names are not equals (shallow array comparison).', () => { 261 | wrapper.rerender(); 262 | 263 | expect(renderTimes).toBe(2); 264 | expect(outerFactory.client().getTreatmentsWithConfig).toBeCalledTimes(2); 265 | expect(outerFactory.client().getTreatmentsWithConfigByFlagSets).toBeCalledTimes(0); 266 | }); 267 | 268 | it('rerenders and re-evaluates feature flags if flag sets are not equals (shallow array comparison).', () => { 269 | wrapper.rerender(); 270 | wrapper.rerender(); 271 | expect(outerFactory.client().getTreatmentsWithConfigByFlagSets).toBeCalledTimes(1); 272 | 273 | wrapper.rerender(); 274 | 275 | expect(renderTimes).toBe(4); 276 | expect(outerFactory.client().getTreatmentsWithConfig).toBeCalledTimes(1); 277 | expect(outerFactory.client().getTreatmentsWithConfigByFlagSets).toBeCalledTimes(2); 278 | }); 279 | 280 | it('rerenders and re-evaluates feature flags if attributes are not equals (shallow object comparison).', () => { 281 | const attributesRef = { ...attributes, att2: 'att2' }; 282 | wrapper.rerender(); 283 | 284 | expect(renderTimes).toBe(2); 285 | expect(outerFactory.client().getTreatmentsWithConfig).toBeCalledTimes(2); 286 | 287 | // If passing same reference but mutated (bad practice), the component re-renders but doesn't re-evaluate feature flags 288 | attributesRef.att2 = 'att2_val2'; 289 | wrapper.rerender(); 290 | expect(renderTimes).toBe(3); 291 | expect(outerFactory.client().getTreatmentsWithConfig).toBeCalledTimes(2); 292 | }); 293 | 294 | it('rerenders and re-evaluates feature flags if lastUpdate timestamp changes (e.g., SDK_UPDATE event).', () => { 295 | expect(renderTimes).toBe(1); 296 | 297 | // State update and split evaluation 298 | act(() => (outerFactory as any).client().__emitter__.emit(Event.SDK_UPDATE)); 299 | expect(outerFactory.client().getTreatmentsWithConfig).toBeCalledTimes(2); 300 | 301 | // State update after destroy doesn't re-evaluate because the sdk is not operational 302 | (outerFactory as any).client().destroy(); 303 | wrapper.rerender(); 304 | 305 | // Updates were batched as a single render, due to automatic batching https://reactjs.org/blog/2022/03/29/react-v18.html#new-feature-automatic-batching 306 | expect(renderTimes).toBe(3); 307 | expect(outerFactory.client().getTreatmentsWithConfig).toBeCalledTimes(2); 308 | 309 | // Restore the client to be READY 310 | (outerFactory as any).client().__restore(); 311 | (outerFactory as any).client().__emitter__.emit(Event.SDK_READY); 312 | }); 313 | 314 | it('rerenders and does not re-evaluate feature flags if lastUpdate timestamp does not change (e.g., SDK_UPDATE event but `updateOnSdkUpdate` false).', () => { 315 | wrapper.rerender(); 316 | expect(renderTimes).toBe(2); 317 | expect(outerFactory.client().getTreatmentsWithConfig).toBeCalledTimes(1); 318 | 319 | // SDK_UPDATE doesn't re-evaluate due to updateOnSdkUpdate false 320 | act(() => (outerFactory as any).client().__emitter__.emit(Event.SDK_UPDATE)); 321 | expect(renderTimes).toBe(3); 322 | expect(outerFactory.client().getTreatmentsWithConfig).toBeCalledTimes(1); 323 | }); 324 | 325 | it('rerenders and re-evaluates feature flags if client changes.', async () => { 326 | wrapper.rerender(); 327 | await act(() => (outerFactory as any).client('otherKey').__emitter__.emit(Event.SDK_READY)); 328 | 329 | // Initial render + 2 renders (in 3 updates) -> automatic batching https://reactjs.org/blog/2022/03/29/react-v18.html#new-feature-automatic-batching 330 | expect(renderTimes).toBe(3); 331 | expect(outerFactory.client().getTreatmentsWithConfig).toBeCalledTimes(1); 332 | expect(outerFactory.client('otherKey').getTreatmentsWithConfig).toBeCalledTimes(1); 333 | }); 334 | 335 | it('rerenders and re-evaluate feature flags when Split context changes (in both SplitFactoryProvider and SplitClient components).', async () => { 336 | // changes in SplitContext implies that either the factory, the client (user key), or its status changed, what might imply a change in treatments 337 | const outerFactory = SplitFactory(sdkBrowser); 338 | let renderTimesComp1 = 0; 339 | let renderTimesComp2 = 0; 340 | 341 | // test context updates on SplitFactoryProvider 342 | render( 343 | 344 | 345 | {() => { 346 | renderTimesComp1++; 347 | return null; 348 | }} 349 | 350 | 351 | ); 352 | 353 | // test context updates on SplitClient 354 | render( 355 | 356 | 357 | 358 | {() => { 359 | renderTimesComp2++; 360 | return null; 361 | }} 362 | 363 | 364 | 365 | ); 366 | 367 | expect(renderTimesComp1).toBe(1); 368 | expect(renderTimesComp2).toBe(1); 369 | 370 | act(() => { 371 | (outerFactory as any).client().__emitter__.emit(Event.SDK_READY_FROM_CACHE); 372 | (outerFactory as any).client('user2').__emitter__.emit(Event.SDK_READY_FROM_CACHE); 373 | }); 374 | 375 | expect(renderTimesComp1).toBe(2); 376 | expect(renderTimesComp2).toBe(1); // updateOnSdkReadyFromCache === false, in second component 377 | 378 | act(() => { 379 | (outerFactory as any).client().__emitter__.emit(Event.SDK_READY_TIMED_OUT); 380 | (outerFactory as any).client('user2').__emitter__.emit(Event.SDK_READY_TIMED_OUT); 381 | }); 382 | 383 | expect(renderTimesComp1).toBe(3); 384 | expect(renderTimesComp2).toBe(2); 385 | 386 | act(() => { 387 | (outerFactory as any).client().__emitter__.emit(Event.SDK_READY); 388 | (outerFactory as any).client('user2').__emitter__.emit(Event.SDK_READY); 389 | }); 390 | 391 | expect(renderTimesComp1).toBe(3); // updateOnSdkReady === false, in first component 392 | expect(renderTimesComp2).toBe(3); 393 | 394 | act(() => { 395 | (outerFactory as any).client().__emitter__.emit(Event.SDK_UPDATE); 396 | (outerFactory as any).client('user2').__emitter__.emit(Event.SDK_UPDATE); 397 | }); 398 | 399 | expect(renderTimesComp1).toBe(4); 400 | expect(renderTimesComp2).toBe(4); 401 | expect(outerFactory.client().getTreatmentsWithConfig).toBeCalledTimes(3); // renderTimes - 1, for the 1st render where SDK is not operational 402 | expect(outerFactory.client('user2').getTreatmentsWithConfig).toBeCalledTimes(3); // idem 403 | }); 404 | 405 | it('rerenders and re-evaluates feature flags if client attributes changes.', (done) => { 406 | const originalFactory = outerFactory; 407 | outerFactory = newSplitFactoryLocalhostInstance(); 408 | 409 | const client = outerFactory.client('emma2'); 410 | const clientSpy = { 411 | getTreatmentsWithConfig: jest.spyOn(client, 'getTreatmentsWithConfig') 412 | } 413 | 414 | client.on(client.Event.SDK_READY, () => { 415 | wrapper = render(); 416 | expect(clientSpy.getTreatmentsWithConfig).toBeCalledTimes(1); 417 | wrapper.rerender(); 418 | expect(renderTimes).toBe(3); 419 | expect(clientSpy.getTreatmentsWithConfig).toBeCalledTimes(2); 420 | 421 | wrapper.rerender(); 422 | expect(renderTimes).toBe(4); 423 | expect(clientSpy.getTreatmentsWithConfig).toBeCalledTimes(3); 424 | 425 | wrapper.rerender(); 426 | expect(renderTimes).toBe(5); 427 | expect(clientSpy.getTreatmentsWithConfig).toBeCalledTimes(3); // not called again. clientAttributes object is shallow equal 428 | 429 | outerFactory = originalFactory; 430 | client.destroy().then(done) 431 | }) 432 | }); 433 | 434 | }); 435 | --------------------------------------------------------------------------------