From f75708f65830dace80b8a1d5efb7eeed663e9a03 Mon Sep 17 00:00:00 2001 From: Emiliano Sanchez Date: Mon, 27 Oct 2025 14:36:31 -0300 Subject: [PATCH 1/7] Added `useTreatment`, `useTreatments`, `useTreatmentWithConfig` and `useTreatmentsWithConfig` hooks to replace the now deprecated `useSplitTreatments` hook --- CHANGES.txt | 2 +- src/__tests__/utils.test.ts | 21 ++-- src/__tests__/withSplitTreatments.test.tsx | 6 +- src/index.ts | 12 ++- src/types.ts | 119 +++++++++++++++++++-- src/useSplitTreatments.ts | 21 +--- src/useTreatment.ts | 35 ++++++ src/useTreatmentWithConfig.ts | 35 ++++++ src/useTreatments.ts | 36 +++++++ src/useTreatmentsWithConfig.ts | 36 +++++++ src/utils.ts | 65 ++++++++--- umd.ts | 2 + 12 files changed, 336 insertions(+), 54 deletions(-) create mode 100644 src/useTreatment.ts create mode 100644 src/useTreatmentWithConfig.ts create mode 100644 src/useTreatments.ts create mode 100644 src/useTreatmentsWithConfig.ts diff --git a/CHANGES.txt b/CHANGES.txt index 032cf2c..3bd5243 100644 --- a/CHANGES.txt +++ b/CHANGES.txt @@ -1,8 +1,8 @@ 2.6.0 (October XX, 2025) + - Added `useTreatment`, `useTreatments`, `useTreatmentWithConfig` and `useTreatmentsWithConfig` hooks to replace the now deprecated `useSplitTreatments` hook. - Updated @splitsoftware/splitio package to version 11.7.1 that includes minor updates: - Added support for custom loggers: added `logger` configuration option and `factory.Logger.setLogger` method to allow the SDK to use a custom logger. - 2.5.0 (September 18, 2025) - Updated @splitsoftware/splitio package to version 11.6.0 that includes minor updates: - Added `storage.wrapper` configuration option to allow the SDK to use a custom storage wrapper for the storage type `LOCALSTORAGE`. Default value is `window.localStorage`. diff --git a/src/__tests__/utils.test.ts b/src/__tests__/utils.test.ts index 3d7e32e..00c5b39 100644 --- a/src/__tests__/utils.test.ts +++ b/src/__tests__/utils.test.ts @@ -1,17 +1,24 @@ -import { CONTROL_WITH_CONFIG } from '../constants'; -import { getControlTreatmentsWithConfig } from '../utils'; +import { CONTROL, CONTROL_WITH_CONFIG } from '../constants'; +import { getControlTreatments } from '../utils'; -describe('getControlTreatmentsWithConfig', () => { +describe('getControlTreatments', () => { it('should return an empty object if an empty array is provided', () => { - expect(Object.values(getControlTreatmentsWithConfig([])).length).toBe(0); + expect(Object.values(getControlTreatments([], true)).length).toBe(0); + expect(Object.values(getControlTreatments([], false)).length).toBe(0); }); - it('should return an empty object if an empty array is provided', () => { + it('should return an object with control treatments if an array of feature flag names is provided', () => { const featureFlagNames = ['split1', 'split2']; - const treatments: SplitIO.TreatmentsWithConfig = getControlTreatmentsWithConfig(featureFlagNames); + const treatmentsWithConfig: SplitIO.TreatmentsWithConfig = getControlTreatments(featureFlagNames, true); + featureFlagNames.forEach((featureFlagName) => { + expect(treatmentsWithConfig[featureFlagName]).toBe(CONTROL_WITH_CONFIG); + }); + expect(Object.keys(treatmentsWithConfig).length).toBe(featureFlagNames.length); + + const treatments: SplitIO.Treatments = getControlTreatments(featureFlagNames, false); featureFlagNames.forEach((featureFlagName) => { - expect(treatments[featureFlagName]).toBe(CONTROL_WITH_CONFIG); + expect(treatments[featureFlagName]).toBe(CONTROL); }); expect(Object.keys(treatments).length).toBe(featureFlagNames.length); }); diff --git a/src/__tests__/withSplitTreatments.test.tsx b/src/__tests__/withSplitTreatments.test.tsx index af178f2..dfaf3c2 100644 --- a/src/__tests__/withSplitTreatments.test.tsx +++ b/src/__tests__/withSplitTreatments.test.tsx @@ -14,14 +14,14 @@ import { INITIAL_STATUS } from './testUtils/utils'; import { withSplitFactory } from '../withSplitFactory'; import { withSplitClient } from '../withSplitClient'; import { withSplitTreatments } from '../withSplitTreatments'; -import { getControlTreatmentsWithConfig } from '../utils'; +import { getControlTreatments } from '../utils'; const featureFlagNames = ['split1', 'split2']; describe('withSplitTreatments', () => { it(`passes Split props and outer props to the child. - In this test, the value of "props.treatments" is obtained by the function "getControlTreatmentsWithConfig", + In this test, the value of "props.treatments" is obtained by the function "getControlTreatments", and not "client.getTreatmentsWithConfig" since the client is not ready.`, () => { const Component = withSplitFactory(sdkBrowser)<{ outerProp1: string, outerProp2: number }>( @@ -36,7 +36,7 @@ describe('withSplitTreatments', () => { ...INITIAL_STATUS, factory: factory, client: clientMock, outerProp1: 'outerProp1', outerProp2: 2, - treatments: getControlTreatmentsWithConfig(featureFlagNames), + treatments: getControlTreatments(featureFlagNames, true), }); return null; diff --git a/src/index.ts b/src/index.ts index 431fe6e..cc69202 100644 --- a/src/index.ts +++ b/src/index.ts @@ -13,6 +13,10 @@ export { SplitFactoryProvider } from './SplitFactoryProvider'; // Hooks export { useTrack } from './useTrack'; +export { useTreatment } from './useTreatment'; +export { useTreatments } from './useTreatments'; +export { useTreatmentWithConfig } from './useTreatmentWithConfig'; +export { useTreatmentsWithConfig } from './useTreatmentsWithConfig'; export { useSplitClient } from './useSplitClient'; export { useSplitTreatments } from './useSplitTreatments'; export { useSplitManager } from './useSplitManager'; @@ -34,5 +38,11 @@ export type { IUpdateProps, IUseSplitClientOptions, IUseSplitTreatmentsOptions, - IUseSplitManagerResult + IUseSplitManagerResult, + IUseTreatmentOptions, + IUseTreatmentsOptions, + IUseTreatmentResult, + IUseTreatmentWithConfigResult, + IUseTreatmentsResult, + IUseTreatmentsWithConfigResult } from './types'; diff --git a/src/types.ts b/src/types.ts index 9493cd5..1701bb4 100644 --- a/src/types.ts +++ b/src/types.ts @@ -170,6 +170,9 @@ export interface ISplitClientProps extends IUseSplitClientOptions { children: ((props: ISplitClientChildProps) => ReactNode) | ReactNode; } +/** + * Result of the `useSplitManager` hook. + */ export interface IUseSplitManagerResult extends ISplitContextValues { /** * Split manager instance. @@ -179,6 +182,17 @@ export interface IUseSplitManagerResult extends ISplitContextValues { manager?: SplitIO.IManager; } +type EvaluationOptions = SplitIO.EvaluationOptions & { + + /** + * An object of type Attributes used to evaluate the feature flags. + */ + attributes?: SplitIO.Attributes; +} + +/** + * @deprecated `useSplitTreatments` will be removed in a future major release. We recommend replacing it with the `useTreatment*` hooks. + */ export type GetTreatmentsOptions = ({ /** @@ -193,27 +207,51 @@ export type GetTreatmentsOptions = ({ */ flagSets: string[]; names?: undefined; -}) & { +}) & EvaluationOptions; - /** - * An object of type Attributes used to evaluate the feature flags. - */ - attributes?: SplitIO.Attributes; +/** + * Options object accepted by the `useSplitTreatments` hook, used to call `client.getTreatmentsWithConfig()`, or `client.getTreatmentsWithConfigByFlagSets()`, + * depending on whether `names` or `flagSets` options are provided, and to retrieve the result along with the Split context. + * + * @deprecated `useSplitTreatments` will be removed in a future major release. We recommend replacing it with the `useTreatment*` hooks. + */ +export type IUseSplitTreatmentsOptions = GetTreatmentsOptions & IUseSplitClientOptions; + +/** + * Options object accepted by the `useTreatment` and `useTreatmentWithConfig` hooks. + */ +export type IUseTreatmentOptions = { /** - * Optional properties to append to the generated impression object sent to Split backend. + * Feature flag name to evaluate. */ - properties?: SplitIO.Properties; -} + name: string; +} & EvaluationOptions & IUseSplitClientOptions; + /** - * Options object accepted by the `useSplitTreatments` hook, used to call `client.getTreatmentsWithConfig()`, or `client.getTreatmentsWithConfigByFlagSets()`, - * depending on whether `names` or `flagSets` options are provided, and to retrieve the result along with the Split context. + * Options object accepted by the `useTreatments` and `useTreatmentsWithConfig` hooks. */ -export type IUseSplitTreatmentsOptions = GetTreatmentsOptions & IUseSplitClientOptions; +export type IUseTreatmentsOptions = ({ + + /** + * List of feature flag names to evaluate. Either this or the `flagSets` property must be provided. If both are provided, the `flagSets` option is ignored. + */ + names: string[]; + flagSets?: undefined; +} | { + + /** + * List of feature flag sets to evaluate. Either this or the `names` property must be provided. If both are provided, the `flagSets` option is ignored. + */ + flagSets: string[]; + names?: undefined; +}) & EvaluationOptions & IUseSplitClientOptions; /** * SplitTreatments Child Props interface. These are the props that the child component receives from the 'SplitTreatments' component. + * + * @deprecated `SplitTreatments` will be removed in a future major release. We recommend replacing it with the `useTreatments*` hooks. */ export interface ISplitTreatmentsChildProps extends ISplitContextValues { @@ -231,9 +269,68 @@ export interface ISplitTreatmentsChildProps extends ISplitContextValues { treatments: SplitIO.TreatmentsWithConfig; } +/** + * Result of the `useTreatment` hook. + */ +export interface IUseTreatmentResult extends ISplitContextValues { + /** + * The treatment string for a feature flag, returned by client.getTreatment(). + */ + treatment: SplitIO.Treatment; +} + +/** + * Result of the `useTreatmentWithConfig` hook. + */ +export interface IUseTreatmentWithConfigResult extends ISplitContextValues { + /** + * The treatment with config for a feature flag, returned by client.getTreatmentWithConfig(). + */ + treatment: SplitIO.TreatmentWithConfig; +} + +/** + * Result of the `useTreatments` hook. + */ +export interface IUseTreatmentsResult extends ISplitContextValues { + /** + * An object with the treatment strings for a bulk of feature flags, returned by client.getTreatments() or client.getTreatmentsByFlagSets(). + * For example: + * + * ```js + * { + * feature1: 'on', + * feature2: 'off' + * } + * ``` + */ + treatments: SplitIO.Treatments; +} + +/** + * Result of the `useTreatmentsWithConfig` hook. + */ +export interface IUseTreatmentsWithConfigResult extends ISplitContextValues { + + /** + * An object with the treatments with configs for a bulk of feature flags, returned by client.getTreatmentsWithConfig() or client.getTreatmentsWithConfigByFlagSets(). + * Each existing configuration is a stringified version of the JSON you defined on the Split user interface. For example: + * + * ```js + * { + * feature1: { treatment: 'on', config: null }, + * feature2: { treatment: 'off', config: '{"bannerText":"Click here."}' } + * } + * ``` + */ + treatments: SplitIO.TreatmentsWithConfig; +} + /** * SplitTreatments Props interface. These are the props accepted by SplitTreatments component, used to call 'client.getTreatmentsWithConfig()', or 'client.getTreatmentsWithConfigByFlagSets()', * depending on whether `names` or `flagSets` props are provided, and to pass the result to the child component. + * + * @deprecated `SplitTreatments` will be removed in a future major release. We recommend replacing it with the `useTreatments*` hooks. */ export type ISplitTreatmentsProps = IUseSplitTreatmentsOptions & { diff --git a/src/useSplitTreatments.ts b/src/useSplitTreatments.ts index 5b672f0..4d7f595 100644 --- a/src/useSplitTreatments.ts +++ b/src/useSplitTreatments.ts @@ -1,7 +1,5 @@ -import * as React from 'react'; -import { memoizeGetTreatmentsWithConfig } from './utils'; import { ISplitTreatmentsChildProps, IUseSplitTreatmentsOptions } from './types'; -import { useSplitClient } from './useSplitClient'; +import { useTreatmentsWithConfig } from '.'; /** * `useSplitTreatments` is a hook that returns an Split Context object extended with a `treatments` property object that contains feature flag evaluations. @@ -17,20 +15,9 @@ import { useSplitClient } from './useSplitClient'; * ``` * * @see {@link https://developer.harness.io/docs/feature-management-experimentation/sdks-and-infrastructure/client-side-sdks/javascript-sdk/#get-treatments-with-configurations} + * + * @deprecated `useSplitTreatments` will be removed in a future major release. We recommend replacing it with the `useTreatment*` hooks. */ export function useSplitTreatments(options: IUseSplitTreatmentsOptions): ISplitTreatmentsChildProps { - const context = useSplitClient({ ...options, attributes: undefined }); - const { client, lastUpdate } = context; - const { names, flagSets, attributes, properties } = options; - - const getTreatmentsWithConfig = React.useMemo(memoizeGetTreatmentsWithConfig, []); - - // Shallow copy `client.getAttributes` result for memoization, as it returns the same reference unless `client.clearAttributes` is invoked. - // Note: the same issue occurs with the `names` and `attributes` arguments if they are mutated directly by the user instead of providing a new object. - const treatments = getTreatmentsWithConfig(client, lastUpdate, names, attributes, client ? { ...client.getAttributes() } : {}, flagSets, properties && { properties }); - - return { - ...context, - treatments, - }; + return useTreatmentsWithConfig(options); } diff --git a/src/useTreatment.ts b/src/useTreatment.ts new file mode 100644 index 0000000..d41438d --- /dev/null +++ b/src/useTreatment.ts @@ -0,0 +1,35 @@ +import * as React from 'react'; +import { memoizeGetTreatment } from './utils'; +import { IUseTreatmentResult, IUseTreatmentOptions } from './types'; +import { useSplitClient } from './useSplitClient'; + +/** + * `useTreatment` is a hook that returns an Split Context object extended with a `treatment` property. + * It uses the `useSplitClient` hook to access the client, and invokes the `client.getTreatment()` method. + * + * @param options - An options object with a feature flag name to evaluate, and an optional `attributes` and `splitKey` values to configure the client. + * @returns A Split Context object extended with a Treatment instance, that might be a control treatment if the client is not available or ready, or if the provided feature flag name does not exist. + * + * @example + * ```js + * const { treatment, isReady, isReadyFromCache, hasTimedout, lastUpdate, ... } = useTreatment({ name: 'feature_1'}); + * ``` + * + * @see {@link https://developer.harness.io/docs/feature-management-experimentation/sdks-and-infrastructure/client-side-sdks/javascript-sdk/#multiple-evaluations-at-once} + */ +export function useTreatment(options: IUseTreatmentOptions): IUseTreatmentResult { + const context = useSplitClient({ ...options, attributes: undefined }); + const { client, lastUpdate } = context; + const { name, attributes, properties } = options; + + const getTreatment = React.useMemo(memoizeGetTreatment, []); + + // Shallow copy `client.getAttributes` result for memoization, as it returns the same reference unless `client.clearAttributes` is invoked. + // Note: the same issue occurs with the `names` and `attributes` arguments if they are mutated directly by the user instead of providing a new object. + const treatment = getTreatment(client, lastUpdate, [name], attributes, client ? { ...client.getAttributes() } : {}, undefined, properties && { properties }); + + return { + ...context, + treatment, + }; +} diff --git a/src/useTreatmentWithConfig.ts b/src/useTreatmentWithConfig.ts new file mode 100644 index 0000000..819396b --- /dev/null +++ b/src/useTreatmentWithConfig.ts @@ -0,0 +1,35 @@ +import * as React from 'react'; +import { memoizeGetTreatmentWithConfig } from './utils'; +import { IUseTreatmentWithConfigResult, IUseTreatmentOptions } from './types'; +import { useSplitClient } from './useSplitClient'; + +/** + * `useTreatmentWithConfig` is a hook that returns an Split Context object extended with a `treatment` property. + * It uses the `useSplitClient` hook to access the client, and invokes the `client.getTreatmentWithConfig()` method. + * + * @param options - An options object with a feature flag name to evaluate, and an optional `attributes` and `splitKey` values to configure the client. + * @returns A Split Context object extended with a TreatmentWithConfig instance, that might be a control treatment if the client is not available or ready, or if the provided feature flag name does not exist. + * + * @example + * ```js + * const { treatment: { treatment, config }, isReady, isReadyFromCache, hasTimedout, lastUpdate, ... } = useTreatmentWithConfig({ name: 'feature_1'}); + * ``` + * + * @see {@link https://developer.harness.io/docs/feature-management-experimentation/sdks-and-infrastructure/client-side-sdks/javascript-sdk/#get-treatments-with-configurations} + */ +export function useTreatmentWithConfig(options: IUseTreatmentOptions): IUseTreatmentWithConfigResult { + const context = useSplitClient({ ...options, attributes: undefined }); + const { client, lastUpdate } = context; + const { name, attributes, properties } = options; + + const getTreatmentWithConfig = React.useMemo(memoizeGetTreatmentWithConfig, []); + + // Shallow copy `client.getAttributes` result for memoization, as it returns the same reference unless `client.clearAttributes` is invoked. + // Note: the same issue occurs with the `names` and `attributes` arguments if they are mutated directly by the user instead of providing a new object. + const treatment = getTreatmentWithConfig(client, lastUpdate, [name], attributes, client ? { ...client.getAttributes() } : {}, undefined, properties && { properties }); + + return { + ...context, + treatment, + }; +} diff --git a/src/useTreatments.ts b/src/useTreatments.ts new file mode 100644 index 0000000..e90cb4e --- /dev/null +++ b/src/useTreatments.ts @@ -0,0 +1,36 @@ +import * as React from 'react'; +import { memoizeGetTreatments } from './utils'; +import { IUseTreatmentsResult, IUseTreatmentsOptions } from './types'; +import { useSplitClient } from './useSplitClient'; + +/** + * `useTreatments` is a hook that returns an Split Context object extended with a `treatments` property object that contains feature flag evaluations. + * It uses the `useSplitClient` hook to access the client, and invokes the `client.getTreatments()` method if the `names` option is provided, + * or the `client.getTreatmentsByFlagSets()` method if the `flagSets` option is provided. + * + * @param options - An options object with a list of feature flag names or flag sets to evaluate, and an optional `attributes` and `splitKey` values to configure the client. + * @returns A Split Context object extended with a Treatments instance, that might contain control treatments if the client is not available or ready, or if feature flag names do not exist. + * + * @example + * ```js + * const { treatments: { feature_1, feature_2 }, isReady, isReadyFromCache, hasTimedout, lastUpdate, ... } = useTreatments({ names: ['feature_1', 'feature_2']}); + * ``` + * + * @see {@link https://developer.harness.io/docs/feature-management-experimentation/sdks-and-infrastructure/client-side-sdks/javascript-sdk/#multiple-evaluations-at-once} + */ +export function useTreatments(options: IUseTreatmentsOptions): IUseTreatmentsResult { + const context = useSplitClient({ ...options, attributes: undefined }); + const { client, lastUpdate } = context; + const { names, flagSets, attributes, properties } = options; + + const getTreatments = React.useMemo(memoizeGetTreatments, []); + + // Shallow copy `client.getAttributes` result for memoization, as it returns the same reference unless `client.clearAttributes` is invoked. + // Note: the same issue occurs with the `names` and `attributes` arguments if they are mutated directly by the user instead of providing a new object. + const treatments = getTreatments(client, lastUpdate, names, attributes, client ? { ...client.getAttributes() } : {}, flagSets, properties && { properties }); + + return { + ...context, + treatments, + }; +} diff --git a/src/useTreatmentsWithConfig.ts b/src/useTreatmentsWithConfig.ts new file mode 100644 index 0000000..f59fe8d --- /dev/null +++ b/src/useTreatmentsWithConfig.ts @@ -0,0 +1,36 @@ +import * as React from 'react'; +import { memoizeGetTreatmentsWithConfig } from './utils'; +import { IUseTreatmentsOptions, IUseTreatmentsWithConfigResult } from './types'; +import { useSplitClient } from './useSplitClient'; + +/** + * `useTreatmentsWithConfig` is a hook that returns an Split Context object extended with a `treatments` property object that contains feature flag evaluations. + * It uses the `useSplitClient` hook to access the client, and invokes the `client.getTreatmentsWithConfig()` method if the `names` option is provided, + * or the `client.getTreatmentsWithConfigByFlagSets()` method if the `flagSets` option is provided. + * + * @param options - An options object with a list of feature flag names or flag sets to evaluate, and an optional `attributes` and `splitKey` values to configure the client. + * @returns A Split Context object extended with a TreatmentsWithConfig instance, that might contain control treatments if the client is not available or ready, or if feature flag names do not exist. + * + * @example + * ```js + * const { treatments: { feature_1, feature_2 }, isReady, isReadyFromCache, hasTimedout, lastUpdate, ... } = useTreatmentsWithConfig({ names: ['feature_1', 'feature_2']}); + * ``` + * + * @see {@link https://developer.harness.io/docs/feature-management-experimentation/sdks-and-infrastructure/client-side-sdks/javascript-sdk/#get-treatments-with-configurations} + */ +export function useTreatmentsWithConfig(options: IUseTreatmentsOptions): IUseTreatmentsWithConfigResult { + const context = useSplitClient({ ...options, attributes: undefined }); + const { client, lastUpdate } = context; + const { names, flagSets, attributes, properties } = options; + + const getTreatmentsWithConfig = React.useMemo(memoizeGetTreatmentsWithConfig, []); + + // Shallow copy `client.getAttributes` result for memoization, as it returns the same reference unless `client.clearAttributes` is invoked. + // Note: the same issue occurs with the `names` and `attributes` arguments if they are mutated directly by the user instead of providing a new object. + const treatments = getTreatmentsWithConfig(client, lastUpdate, names, attributes, client ? { ...client.getAttributes() } : {}, flagSets, properties && { properties }); + + return { + ...context, + treatments, + }; +} diff --git a/src/utils.ts b/src/utils.ts index e7c5797..da82203 100644 --- a/src/utils.ts +++ b/src/utils.ts @@ -1,6 +1,6 @@ import memoizeOne from 'memoize-one'; import shallowEqual from 'shallowequal'; -import { CONTROL_WITH_CONFIG } from './constants'; +import { CONTROL, CONTROL_WITH_CONFIG } from './constants'; import { ISplitStatus } from './types'; // Utils used to access singleton instances of Split factories and clients, and to gracefully shutdown all clients together. @@ -60,7 +60,10 @@ export function initAttributes(client?: SplitIO.IBrowserClient, attributes?: Spl if (client && attributes) client.setAttributes(attributes); } -export function getControlTreatmentsWithConfig(featureFlagNames: unknown): SplitIO.TreatmentsWithConfig { +export function getControlTreatments(featureFlagNames: unknown, withConfig: true): SplitIO.TreatmentsWithConfig; +export function getControlTreatments(featureFlagNames: unknown, withConfig: false): SplitIO.Treatments; +export function getControlTreatments(featureFlagNames: unknown, withConfig: boolean): SplitIO.Treatments | SplitIO.TreatmentsWithConfig { + // validate feature flag names if (!Array.isArray(featureFlagNames)) return {}; featureFlagNames = featureFlagNames @@ -69,8 +72,8 @@ export function getControlTreatmentsWithConfig(featureFlagNames: unknown): Split .filter((featureFlagName) => featureFlagName.length > 0); // return control treatments for each validated feature flag name - return (featureFlagNames as string[]).reduce((pValue: SplitIO.TreatmentsWithConfig, cValue: string) => { - pValue[cValue] = CONTROL_WITH_CONFIG; + return (featureFlagNames as string[]).reduce((pValue: SplitIO.Treatments | SplitIO.TreatmentsWithConfig, cValue: string) => { + pValue[cValue] = withConfig ? CONTROL_WITH_CONFIG : CONTROL; return pValue; }, {}); } @@ -82,14 +85,6 @@ function isString(val: unknown): val is string { return typeof val === 'string' || val instanceof String; } -/** - * Gets a memoized version of the `client.getTreatmentsWithConfig` method. - * It is used to avoid duplicated impressions, because the result treatments are the same given the same `client` instance, `lastUpdate` timestamp, and list of feature flag `names` and `attributes`. - */ -export function memoizeGetTreatmentsWithConfig() { - return memoizeOne(evaluateFeatureFlags, argsAreEqual); -} - function argsAreEqual(newArgs: any[], lastArgs: any[]): boolean { return newArgs[0] === lastArgs[0] && // client newArgs[1] === lastArgs[1] && // lastUpdate @@ -99,12 +94,54 @@ function argsAreEqual(newArgs: any[], lastArgs: any[]): boolean { shallowEqual(newArgs[5], lastArgs[5]); // flagSets } -function evaluateFeatureFlags(client: SplitIO.IBrowserClient | undefined, _lastUpdate: number, names?: SplitIO.SplitNames, attributes?: SplitIO.Attributes, _clientAttributes?: SplitIO.Attributes, flagSets?: string[], options?: SplitIO.EvaluationOptions) { +function evaluateFeatureFlagsWithConfig(client: SplitIO.IBrowserClient | undefined, _lastUpdate: number, names?: SplitIO.SplitNames, attributes?: SplitIO.Attributes, _clientAttributes?: SplitIO.Attributes, flagSets?: string[], options?: SplitIO.EvaluationOptions) { return client && (client as IClientWithContext).__getStatus().isOperational && (names || flagSets) ? names ? client.getTreatmentsWithConfig(names, attributes, options) : client.getTreatmentsWithConfigByFlagSets(flagSets!, attributes, options) : names ? - getControlTreatmentsWithConfig(names) : + getControlTreatments(names, true) : + {} // empty object when evaluating with flag sets and client is not ready +} + +/** + * Gets a memoized version of the `client.getTreatmentsWithConfig` method. + * It is used to avoid duplicated impressions, because the result treatments are the same given the same `client` instance, `lastUpdate` timestamp, and list of feature flag `names` and `attributes`. + */ +export function memoizeGetTreatmentsWithConfig() { + return memoizeOne(evaluateFeatureFlagsWithConfig, argsAreEqual); +} + +function evaluateFeatureFlags(client: SplitIO.IBrowserClient | undefined, _lastUpdate: number, names?: SplitIO.SplitNames, attributes?: SplitIO.Attributes, _clientAttributes?: SplitIO.Attributes, flagSets?: string[], options?: SplitIO.EvaluationOptions) { + return client && (client as IClientWithContext).__getStatus().isOperational && (names || flagSets) ? + names ? + client.getTreatments(names, attributes, options) : + client.getTreatmentsByFlagSets(flagSets!, attributes, options) : + names ? + getControlTreatments(names, false) : {} // empty object when evaluating with flag sets and client is not ready } + +export function memoizeGetTreatments() { + return memoizeOne(evaluateFeatureFlags, argsAreEqual); +} + +function evaluateFeatureFlagWithConfig(client: SplitIO.IBrowserClient | undefined, _lastUpdate: number, names: string[], attributes?: SplitIO.Attributes, _clientAttributes?: SplitIO.Attributes, _flagSets?: undefined, options?: SplitIO.EvaluationOptions) { + return client && (client as IClientWithContext).__getStatus().isOperational ? + client.getTreatmentWithConfig(names[0], attributes, options) : + CONTROL_WITH_CONFIG +} + +export function memoizeGetTreatmentWithConfig() { + return memoizeOne(evaluateFeatureFlagWithConfig, argsAreEqual); +} + +function evaluateFeatureFlag(client: SplitIO.IBrowserClient | undefined, _lastUpdate: number, names: string[], attributes?: SplitIO.Attributes, _clientAttributes?: SplitIO.Attributes, _flagSets?: undefined, options?: SplitIO.EvaluationOptions) { + return client && (client as IClientWithContext).__getStatus().isOperational ? + client.getTreatment(names[0], attributes, options) : + CONTROL; +} + +export function memoizeGetTreatment() { + return memoizeOne(evaluateFeatureFlag, argsAreEqual); +} diff --git a/umd.ts b/umd.ts index 351c5d3..d9a5dcb 100644 --- a/umd.ts +++ b/umd.ts @@ -3,6 +3,7 @@ import { withSplitFactory, withSplitClient, withSplitTreatments, SplitFactoryProvider, SplitClient, SplitTreatments, useSplitClient, useSplitTreatments, useTrack, useSplitManager, + useTreatment, useTreatments, useTreatmentWithConfig, useTreatmentsWithConfig, SplitContext, } from './src/index'; @@ -11,5 +12,6 @@ export default { withSplitFactory, withSplitClient, withSplitTreatments, SplitFactoryProvider, SplitClient, SplitTreatments, useSplitClient, useSplitTreatments, useTrack, useSplitManager, + useTreatment, useTreatments, useTreatmentWithConfig, useTreatmentsWithConfig, SplitContext, }; From b35dc782cbc7c8356dd140d72f2beba02f50dfab Mon Sep 17 00:00:00 2001 From: Emiliano Sanchez Date: Mon, 27 Oct 2025 14:48:34 -0300 Subject: [PATCH 2/7] Add tests --- src/__tests__/index.test.ts | 12 + src/__tests__/testUtils/mockSplitFactory.ts | 23 ++ src/__tests__/useTreatment.test.tsx | 174 +++++++++++++ src/__tests__/useTreatmentWithConfig.test.tsx | 174 +++++++++++++ src/__tests__/useTreatments.test.tsx | 229 ++++++++++++++++++ ...t.tsx => useTreatmentsWithConfig.test.tsx} | 52 ++-- 6 files changed, 638 insertions(+), 26 deletions(-) create mode 100644 src/__tests__/useTreatment.test.tsx create mode 100644 src/__tests__/useTreatmentWithConfig.test.tsx create mode 100644 src/__tests__/useTreatments.test.tsx rename src/__tests__/{useSplitTreatments.test.tsx => useTreatmentsWithConfig.test.tsx} (77%) diff --git a/src/__tests__/index.test.ts b/src/__tests__/index.test.ts index a455af6..d63b749 100644 --- a/src/__tests__/index.test.ts +++ b/src/__tests__/index.test.ts @@ -12,6 +12,10 @@ import { useSplitClient as exportedUseSplitClient, useSplitTreatments as exportedUseSplitTreatments, useSplitManager as exportedUseSplitManager, + useTreatment as exportedUseTreatment, + useTreatmentWithConfig as exportedUseTreatmentWithConfig, + useTreatments as exportedUseTreatments, + useTreatmentsWithConfig as exportedUseTreatmentsWithConfig, // Checks that types are exported. Otherwise, the test would fail with a TS error. GetTreatmentsOptions, ISplitClientChildProps, @@ -39,6 +43,10 @@ import { useTrack } from '../useTrack'; import { useSplitClient } from '../useSplitClient'; import { useSplitTreatments } from '../useSplitTreatments'; import { useSplitManager } from '../useSplitManager'; +import { useTreatment } from '../useTreatment'; +import { useTreatmentWithConfig } from '../useTreatmentWithConfig'; +import { useTreatments } from '../useTreatments'; +import { useTreatmentsWithConfig } from '../useTreatmentsWithConfig'; describe('index', () => { @@ -59,6 +67,10 @@ describe('index', () => { expect(exportedUseSplitClient).toBe(useSplitClient); expect(exportedUseSplitTreatments).toBe(useSplitTreatments); expect(exportedUseSplitManager).toBe(useSplitManager); + expect(exportedUseTreatment).toBe(useTreatment); + expect(exportedUseTreatmentWithConfig).toBe(useTreatmentWithConfig); + expect(exportedUseTreatments).toBe(useTreatments); + expect(exportedUseTreatmentsWithConfig).toBe(useTreatmentsWithConfig); }); it('should export SplitContext', () => { diff --git a/src/__tests__/testUtils/mockSplitFactory.ts b/src/__tests__/testUtils/mockSplitFactory.ts index 2e2808e..a4068dc 100644 --- a/src/__tests__/testUtils/mockSplitFactory.ts +++ b/src/__tests__/testUtils/mockSplitFactory.ts @@ -1,6 +1,7 @@ import { EventEmitter } from 'events'; import jsSdkPackageJson from '@splitsoftware/splitio/package.json'; import reactSdkPackageJson from '../../../package.json'; +import { CONTROL, CONTROL_WITH_CONFIG } from '../../constants'; export const jsSdkVersion = `javascript-${jsSdkPackageJson.version}`; export const reactSdkVersion = `react-${reactSdkPackageJson.version}`; @@ -65,6 +66,24 @@ export function mockSdk() { const track: jest.Mock = jest.fn(() => { return true; }); + const getTreatment: jest.Mock = jest.fn((featureFlagName: string) => { + return typeof featureFlagName === 'string' ? 'on' : CONTROL; + }); + const getTreatments: jest.Mock = jest.fn((featureFlagNames: string[]) => { + return featureFlagNames.reduce((result: SplitIO.Treatments, featureName: string) => { + result[featureName] = 'on'; + return result; + }, {}); + }); + const getTreatmentsByFlagSets: jest.Mock = jest.fn((flagSets: string[]) => { + return flagSets.reduce((result: SplitIO.Treatments, flagSet: string) => { + result[flagSet + '_feature_flag'] = 'on'; + return result; + }, {}); + }); + const getTreatmentWithConfig: jest.Mock = jest.fn((featureFlagName: string) => { + return typeof featureFlagName === 'string' ? { treatment: 'on', config: null } : CONTROL_WITH_CONFIG; + }); const getTreatmentsWithConfig: jest.Mock = jest.fn((featureFlagNames: string[]) => { return featureFlagNames.reduce((result: SplitIO.TreatmentsWithConfig, featureName: string) => { result[featureName] = { treatment: 'on', config: null }; @@ -113,6 +132,10 @@ export function mockSdk() { }); return Object.assign(Object.create(__emitter__), { + getTreatment, + getTreatments, + getTreatmentsByFlagSets, + getTreatmentWithConfig, getTreatmentsWithConfig, getTreatmentsWithConfigByFlagSets, track, diff --git a/src/__tests__/useTreatment.test.tsx b/src/__tests__/useTreatment.test.tsx new file mode 100644 index 0000000..106131e --- /dev/null +++ b/src/__tests__/useTreatment.test.tsx @@ -0,0 +1,174 @@ +import * as React from 'react'; +import { act, render } from '@testing-library/react'; + +/** Mocks */ +import { mockSdk, Event } from './testUtils/mockSplitFactory'; +jest.mock('@splitsoftware/splitio/client', () => { + return { SplitFactory: mockSdk() }; +}); +import { SplitFactory } from '@splitsoftware/splitio/client'; +import { sdkBrowser } from './testUtils/sdkConfigs'; +import { CONTROL, EXCEPTION_NO_SFP } from '../constants'; + +/** Test target */ +import { SplitFactoryProvider } from '../SplitFactoryProvider'; +import { useTreatment } from '../useTreatment'; +import { SplitContext } from '../SplitContext'; +import { IUseTreatmentResult } from '../types'; + +describe('useTreatment', () => { + + const featureFlagName = 'split1'; + const attributes = { att1: 'att1' }; + const properties = { prop1: 'prop1' }; + + test('returns the treatment evaluated by the main client of the factory at Split context, or control if the client is not operational.', () => { + const outerFactory = SplitFactory(sdkBrowser); + const client: any = outerFactory.client(); + let treatment: SplitIO.Treatment; + + render( + + {React.createElement(() => { + treatment = useTreatment({ name: featureFlagName, attributes, properties }).treatment; + return null; + })} + + ); + + // returns control treatment if not operational (SDK not ready or destroyed), without calling `getTreatment` method + expect(client.getTreatment).not.toBeCalled(); + expect(treatment!).toEqual(CONTROL); + + // once operational (SDK_READY), it evaluates feature flags + act(() => client.__emitter__.emit(Event.SDK_READY)); + + expect(client.getTreatment).toBeCalledWith(featureFlagName, attributes, { properties }); + expect(client.getTreatment).toHaveReturnedWith(treatment!); + }); + + test('returns the treatments from a new client given a splitKey, and re-evaluates on SDK events.', () => { + const outerFactory = SplitFactory(sdkBrowser); + const client: any = outerFactory.client('user2'); + let renderTimes = 0; + + render( + + {React.createElement(() => { + const treatment = useTreatment({ name: featureFlagName, attributes, properties, splitKey: 'user2', updateOnSdkUpdate: false }).treatment; + + renderTimes++; + switch (renderTimes) { + case 1: + // returns control if not operational (SDK not ready), without calling `getTreatment` method + expect(client.getTreatment).not.toBeCalled(); + expect(treatment).toEqual(CONTROL); + break; + case 2: + case 3: + // once operational (SDK_READY or SDK_READY_FROM_CACHE), it evaluates feature flags + expect(client.getTreatment).toHaveBeenLastCalledWith(featureFlagName, attributes, { properties }); + expect(client.getTreatment).toHaveLastReturnedWith(treatment); + break; + default: + throw new Error('Unexpected render'); + } + + return null; + })} + + ); + + act(() => client.__emitter__.emit(Event.SDK_READY_FROM_CACHE)); + act(() => client.__emitter__.emit(Event.SDK_READY)); + act(() => client.__emitter__.emit(Event.SDK_UPDATE)); + expect(client.getTreatment).toBeCalledTimes(2); + }); + + test('throws error if invoked outside of SplitFactoryProvider.', () => { + expect(() => { + render( + React.createElement(() => { + useTreatment({ name: featureFlagName, attributes }).treatment; + return null; + }) + ); + }).toThrow(EXCEPTION_NO_SFP); + }); + + test('useTreatment must update on SDK events', async () => { + const outerFactory = SplitFactory(sdkBrowser); + const mainClient = outerFactory.client() as any; + const user2Client = outerFactory.client('user_2') as any; + + let countSplitContext = 0, countUseTreatment = 0, countUseTreatmentUser2 = 0, countUseTreatmentUser2WithoutUpdate = 0; + const lastUpdateSetUser2 = new Set(); + const lastUpdateSetUser2WithUpdate = new Set(); + + function validateTreatment({ treatment, isReady, isReadyFromCache }: IUseTreatmentResult) { + if (isReady || isReadyFromCache) { + expect(treatment).toEqual('on') + } else { + expect(treatment).toEqual('control') + } + } + + render( + + <> + + {() => countSplitContext++} + + {React.createElement(() => { + const context = useTreatment({ name: 'split_test', attributes: { att1: 'att1' } }); + expect(context.client).toBe(mainClient); // Assert that the main client was retrieved. + validateTreatment(context); + countUseTreatment++; + return null; + })} + {React.createElement(() => { + const context = useTreatment({ name: 'split_test', splitKey: 'user_2' }); + expect(context.client).toBe(user2Client); + validateTreatment(context); + lastUpdateSetUser2.add(context.lastUpdate); + countUseTreatmentUser2++; + return null; + })} + {React.createElement(() => { + const context = useTreatment({ name: 'split_test', splitKey: 'user_2', updateOnSdkUpdate: false }); + expect(context.client).toBe(user2Client); + validateTreatment(context); + lastUpdateSetUser2WithUpdate.add(context.lastUpdate); + countUseTreatmentUser2WithoutUpdate++; + return null; + })} + + + ); + + act(() => mainClient.__emitter__.emit(Event.SDK_READY_FROM_CACHE)); + act(() => mainClient.__emitter__.emit(Event.SDK_READY)); + act(() => mainClient.__emitter__.emit(Event.SDK_UPDATE)); + act(() => user2Client.__emitter__.emit(Event.SDK_READY_FROM_CACHE)); + act(() => user2Client.__emitter__.emit(Event.SDK_READY)); + act(() => user2Client.__emitter__.emit(Event.SDK_UPDATE)); + + // SplitFactoryProvider renders once + expect(countSplitContext).toEqual(1); + + // If useTreatment evaluates with the main client and have default update options, it re-renders for each main client event. + expect(countUseTreatment).toEqual(4); + expect(mainClient.getTreatment).toHaveBeenCalledTimes(3); // when ready from cache, ready and update + expect(mainClient.getTreatment).toHaveBeenLastCalledWith('split_test', { att1: 'att1' }, undefined); + + // If useTreatment evaluates with a different client and have default update options, it re-renders for each event of the new client. + expect(countUseTreatmentUser2).toEqual(4); + expect(lastUpdateSetUser2.size).toEqual(4); + // If it is used with `updateOnSdkUpdate: false`, it doesn't render when the client emits an SDK_UPDATE event. + expect(countUseTreatmentUser2WithoutUpdate).toEqual(3); + expect(lastUpdateSetUser2WithUpdate.size).toEqual(3); + expect(user2Client.getTreatment).toHaveBeenCalledTimes(5); // when ready from cache x2, ready x2 and update x1 + expect(user2Client.getTreatment).toHaveBeenLastCalledWith('split_test', undefined, undefined); + }); + +}); diff --git a/src/__tests__/useTreatmentWithConfig.test.tsx b/src/__tests__/useTreatmentWithConfig.test.tsx new file mode 100644 index 0000000..a296126 --- /dev/null +++ b/src/__tests__/useTreatmentWithConfig.test.tsx @@ -0,0 +1,174 @@ +import * as React from 'react'; +import { act, render } from '@testing-library/react'; + +/** Mocks */ +import { mockSdk, Event } from './testUtils/mockSplitFactory'; +jest.mock('@splitsoftware/splitio/client', () => { + return { SplitFactory: mockSdk() }; +}); +import { SplitFactory } from '@splitsoftware/splitio/client'; +import { sdkBrowser } from './testUtils/sdkConfigs'; +import { CONTROL_WITH_CONFIG, EXCEPTION_NO_SFP } from '../constants'; + +/** Test target */ +import { SplitFactoryProvider } from '../SplitFactoryProvider'; +import { useTreatmentWithConfig } from '../useTreatmentWithConfig'; +import { SplitContext } from '../SplitContext'; +import { IUseTreatmentWithConfigResult } from '../types'; + +describe('useTreatmentWithConfig', () => { + + const featureFlagName = 'split1'; + const attributes = { att1: 'att1' }; + const properties = { prop1: 'prop1' }; + + test('returns the treatment evaluated by the main client of the factory at Split context, or control if the client is not operational.', () => { + const outerFactory = SplitFactory(sdkBrowser); + const client: any = outerFactory.client(); + let treatment: SplitIO.TreatmentWithConfig; + + render( + + {React.createElement(() => { + treatment = useTreatmentWithConfig({ name: featureFlagName, attributes, properties }).treatment; + return null; + })} + + ); + + // returns control treatment if not operational (SDK not ready or destroyed), without calling `getTreatmentWithConfig` method + expect(client.getTreatmentWithConfig).not.toBeCalled(); + expect(treatment!).toEqual(CONTROL_WITH_CONFIG); + + // once operational (SDK_READY), it evaluates feature flags + act(() => client.__emitter__.emit(Event.SDK_READY)); + + expect(client.getTreatmentWithConfig).toBeCalledWith(featureFlagName, attributes, { properties }); + expect(client.getTreatmentWithConfig).toHaveReturnedWith(treatment!); + }); + + test('returns the treatments from a new client given a splitKey, and re-evaluates on SDK events.', () => { + const outerFactory = SplitFactory(sdkBrowser); + const client: any = outerFactory.client('user2'); + let renderTimes = 0; + + render( + + {React.createElement(() => { + const treatment = useTreatmentWithConfig({ name: featureFlagName, attributes, properties, splitKey: 'user2', updateOnSdkUpdate: false }).treatment; + + renderTimes++; + switch (renderTimes) { + case 1: + // returns control if not operational (SDK not ready), without calling `getTreatmentWithConfig` method + expect(client.getTreatmentWithConfig).not.toBeCalled(); + expect(treatment).toEqual(CONTROL_WITH_CONFIG); + break; + case 2: + case 3: + // once operational (SDK_READY or SDK_READY_FROM_CACHE), it evaluates feature flags + expect(client.getTreatmentWithConfig).toHaveBeenLastCalledWith(featureFlagName, attributes, { properties }); + expect(client.getTreatmentWithConfig).toHaveLastReturnedWith(treatment); + break; + default: + throw new Error('Unexpected render'); + } + + return null; + })} + + ); + + act(() => client.__emitter__.emit(Event.SDK_READY_FROM_CACHE)); + act(() => client.__emitter__.emit(Event.SDK_READY)); + act(() => client.__emitter__.emit(Event.SDK_UPDATE)); + expect(client.getTreatmentWithConfig).toBeCalledTimes(2); + }); + + test('throws error if invoked outside of SplitFactoryProvider.', () => { + expect(() => { + render( + React.createElement(() => { + useTreatmentWithConfig({ name: featureFlagName, attributes }).treatment; + return null; + }) + ); + }).toThrow(EXCEPTION_NO_SFP); + }); + + test('useTreatmentWithConfig must update on SDK events', async () => { + const outerFactory = SplitFactory(sdkBrowser); + const mainClient = outerFactory.client() as any; + const user2Client = outerFactory.client('user_2') as any; + + let countSplitContext = 0, countUseTreatmentWithConfig = 0, countUseTreatmentWithConfigUser2 = 0, countUseTreatmentWithConfigUser2WithoutUpdate = 0; + const lastUpdateSetUser2 = new Set(); + const lastUpdateSetUser2WithUpdate = new Set(); + + function validateTreatment({ treatment, isReady, isReadyFromCache }: IUseTreatmentWithConfigResult) { + if (isReady || isReadyFromCache) { + expect(treatment).toEqual({ treatment: 'on', config: null }) + } else { + expect(treatment).toEqual({ treatment: 'control', config: null }) + } + } + + render( + + <> + + {() => countSplitContext++} + + {React.createElement(() => { + const context = useTreatmentWithConfig({ name: 'split_test', attributes: { att1: 'att1' } }); + expect(context.client).toBe(mainClient); // Assert that the main client was retrieved. + validateTreatment(context); + countUseTreatmentWithConfig++; + return null; + })} + {React.createElement(() => { + const context = useTreatmentWithConfig({ name: 'split_test', splitKey: 'user_2' }); + expect(context.client).toBe(user2Client); + validateTreatment(context); + lastUpdateSetUser2.add(context.lastUpdate); + countUseTreatmentWithConfigUser2++; + return null; + })} + {React.createElement(() => { + const context = useTreatmentWithConfig({ name: 'split_test', splitKey: 'user_2', updateOnSdkUpdate: false }); + expect(context.client).toBe(user2Client); + validateTreatment(context); + lastUpdateSetUser2WithUpdate.add(context.lastUpdate); + countUseTreatmentWithConfigUser2WithoutUpdate++; + return null; + })} + + + ); + + act(() => mainClient.__emitter__.emit(Event.SDK_READY_FROM_CACHE)); + act(() => mainClient.__emitter__.emit(Event.SDK_READY)); + act(() => mainClient.__emitter__.emit(Event.SDK_UPDATE)); + act(() => user2Client.__emitter__.emit(Event.SDK_READY_FROM_CACHE)); + act(() => user2Client.__emitter__.emit(Event.SDK_READY)); + act(() => user2Client.__emitter__.emit(Event.SDK_UPDATE)); + + // SplitFactoryProvider renders once + expect(countSplitContext).toEqual(1); + + // If useTreatmentWithConfig evaluates with the main client and have default update options, it re-renders for each main client event. + expect(countUseTreatmentWithConfig).toEqual(4); + expect(mainClient.getTreatmentWithConfig).toHaveBeenCalledTimes(3); // when ready from cache, ready and update + expect(mainClient.getTreatmentWithConfig).toHaveBeenLastCalledWith('split_test', { att1: 'att1' }, undefined); + + // If useTreatmentWithConfig evaluates with a different client and have default update options, it re-renders for each event of the new client. + expect(countUseTreatmentWithConfigUser2).toEqual(4); + expect(lastUpdateSetUser2.size).toEqual(4); + // If it is used with `updateOnSdkUpdate: false`, it doesn't render when the client emits an SDK_UPDATE event. + expect(countUseTreatmentWithConfigUser2WithoutUpdate).toEqual(3); + expect(lastUpdateSetUser2WithUpdate.size).toEqual(3); + expect(user2Client.getTreatmentWithConfig).toHaveBeenCalledTimes(5); // when ready from cache x2, ready x2 and update x1 + expect(user2Client.getTreatmentWithConfig).toHaveBeenLastCalledWith('split_test', undefined, undefined); + }); + +}); diff --git a/src/__tests__/useTreatments.test.tsx b/src/__tests__/useTreatments.test.tsx new file mode 100644 index 0000000..9b5bb57 --- /dev/null +++ b/src/__tests__/useTreatments.test.tsx @@ -0,0 +1,229 @@ +import * as React from 'react'; +import { act, render } from '@testing-library/react'; + +/** Mocks */ +import { mockSdk, Event } from './testUtils/mockSplitFactory'; +jest.mock('@splitsoftware/splitio/client', () => { + return { SplitFactory: mockSdk() }; +}); +import { SplitFactory } from '@splitsoftware/splitio/client'; +import { sdkBrowser } from './testUtils/sdkConfigs'; +import { CONTROL, EXCEPTION_NO_SFP } from '../constants'; + +/** Test target */ +import { SplitFactoryProvider } from '../SplitFactoryProvider'; +import { useTreatments } from '../useTreatments'; +import { SplitContext } from '../SplitContext'; +import { IUseTreatmentsResult } from '../types'; + +describe('useTreatments', () => { + + const featureFlagNames = ['split1']; + const flagSets = ['set1']; + const attributes = { att1: 'att1' }; + const properties = { prop1: 'prop1' }; + + test('returns the treatments evaluated by the main client of the factory at Split context, or control if the client is not operational.', () => { + const outerFactory = SplitFactory(sdkBrowser); + const client: any = outerFactory.client(); + let treatments: SplitIO.Treatments; + let treatmentsByFlagSets: SplitIO.Treatments; + + render( + + {React.createElement(() => { + treatments = useTreatments({ names: featureFlagNames, attributes, properties }).treatments; + treatmentsByFlagSets = useTreatments({ flagSets, attributes, properties }).treatments; + + // @ts-expect-error Options object must provide either names or flagSets + expect(useTreatments({}).treatments).toEqual({}); + return null; + })} + + ); + + // returns control treatment if not operational (SDK not ready or destroyed), without calling `getTreatments` method + expect(client.getTreatments).not.toBeCalled(); + expect(treatments!).toEqual({ split1: CONTROL }); + + // returns empty treatments object if not operational, without calling `getTreatmentsByFlagSets` method + expect(client.getTreatmentsByFlagSets).not.toBeCalled(); + expect(treatmentsByFlagSets!).toEqual({}); + + // once operational (SDK_READY), it evaluates feature flags + act(() => client.__emitter__.emit(Event.SDK_READY)); + + expect(client.getTreatments).toBeCalledWith(featureFlagNames, attributes, { properties }); + expect(client.getTreatments).toHaveReturnedWith(treatments!); + + expect(client.getTreatmentsByFlagSets).toBeCalledWith(flagSets, attributes, { properties }); + expect(client.getTreatmentsByFlagSets).toHaveReturnedWith(treatmentsByFlagSets!); + }); + + test('returns the treatments from a new client given a splitKey, and re-evaluates on SDK events.', () => { + const outerFactory = SplitFactory(sdkBrowser); + const client: any = outerFactory.client('user2'); + let renderTimes = 0; + + render( + + {React.createElement(() => { + const treatments = useTreatments({ names: featureFlagNames, attributes, properties, splitKey: 'user2', updateOnSdkUpdate: false }).treatments; + + renderTimes++; + switch (renderTimes) { + case 1: + // returns control if not operational (SDK not ready), without calling `getTreatments` method + expect(client.getTreatments).not.toBeCalled(); + expect(treatments).toEqual({ split1: CONTROL }); + break; + case 2: + case 3: + // once operational (SDK_READY or SDK_READY_FROM_CACHE), it evaluates feature flags + expect(client.getTreatments).toHaveBeenLastCalledWith(featureFlagNames, attributes, { properties }); + expect(client.getTreatments).toHaveLastReturnedWith(treatments); + break; + default: + throw new Error('Unexpected render'); + } + + return null; + })} + + ); + + act(() => client.__emitter__.emit(Event.SDK_READY_FROM_CACHE)); + act(() => client.__emitter__.emit(Event.SDK_READY)); + act(() => client.__emitter__.emit(Event.SDK_UPDATE)); + expect(client.getTreatments).toBeCalledTimes(2); + }); + + test('throws error if invoked outside of SplitFactoryProvider.', () => { + expect(() => { + render( + React.createElement(() => { + useTreatments({ names: featureFlagNames, attributes }).treatments; + useTreatments({ flagSets: featureFlagNames }).treatments; + return null; + }) + ); + }).toThrow(EXCEPTION_NO_SFP); + }); + + /** + * Input validation: sanitize invalid feature flag names and return control while the SDK is not ready. + */ + test('Input validation: invalid names are sanitized.', () => { + render( + + { + React.createElement(() => { + // @ts-expect-error Test error handling + let treatments = useTreatments('split1').treatments; + expect(treatments).toEqual({}); + // @ts-expect-error Test error handling + treatments = useTreatments({ names: [true, ' flag_1 ', ' '] }).treatments; + expect(treatments).toEqual({ flag_1: CONTROL }); + + return null; + }) + } + + ); + }); + + test('useTreatments must update on SDK events', async () => { + const outerFactory = SplitFactory(sdkBrowser); + const mainClient = outerFactory.client() as any; + const user2Client = outerFactory.client('user_2') as any; + + let countSplitContext = 0, countUseTreatments = 0, countUseTreatmentsUser2 = 0, countUseTreatmentsUser2WithoutUpdate = 0; + const lastUpdateSetUser2 = new Set(); + const lastUpdateSetUser2WithUpdate = new Set(); + + function validateTreatments({ treatments, isReady, isReadyFromCache }: IUseTreatmentsResult) { + if (isReady || isReadyFromCache) { + expect(treatments).toEqual({ + split_test: 'on' + }) + } else { + expect(treatments).toEqual({ + split_test: 'control' + }) + } + } + + render( + + <> + + {() => countSplitContext++} + + {React.createElement(() => { + const context = useTreatments({ names: ['split_test'], attributes: { att1: 'att1' } }); + expect(context.client).toBe(mainClient); // Assert that the main client was retrieved. + validateTreatments(context); + countUseTreatments++; + return null; + })} + {React.createElement(() => { + const context = useTreatments({ names: ['split_test'], splitKey: 'user_2' }); + expect(context.client).toBe(user2Client); + validateTreatments(context); + lastUpdateSetUser2.add(context.lastUpdate); + countUseTreatmentsUser2++; + return null; + })} + {React.createElement(() => { + const context = useTreatments({ names: ['split_test'], splitKey: 'user_2', updateOnSdkUpdate: false }); + expect(context.client).toBe(user2Client); + validateTreatments(context); + lastUpdateSetUser2WithUpdate.add(context.lastUpdate); + countUseTreatmentsUser2WithoutUpdate++; + return null; + })} + + + ); + + act(() => mainClient.__emitter__.emit(Event.SDK_READY_FROM_CACHE)); + act(() => mainClient.__emitter__.emit(Event.SDK_READY)); + act(() => mainClient.__emitter__.emit(Event.SDK_UPDATE)); + act(() => user2Client.__emitter__.emit(Event.SDK_READY_FROM_CACHE)); + act(() => user2Client.__emitter__.emit(Event.SDK_READY)); + act(() => user2Client.__emitter__.emit(Event.SDK_UPDATE)); + + // SplitFactoryProvider renders once + expect(countSplitContext).toEqual(1); + + // If useTreatments evaluates with the main client and have default update options, it re-renders for each main client event. + expect(countUseTreatments).toEqual(4); + expect(mainClient.getTreatments).toHaveBeenCalledTimes(3); // when ready from cache, ready and update + expect(mainClient.getTreatments).toHaveBeenLastCalledWith(['split_test'], { att1: 'att1' }, undefined); + + // If useTreatments evaluates with a different client and have default update options, it re-renders for each event of the new client. + expect(countUseTreatmentsUser2).toEqual(4); + expect(lastUpdateSetUser2.size).toEqual(4); + // If it is used with `updateOnSdkUpdate: false`, it doesn't render when the client emits an SDK_UPDATE event. + expect(countUseTreatmentsUser2WithoutUpdate).toEqual(3); + expect(lastUpdateSetUser2WithUpdate.size).toEqual(3); + expect(user2Client.getTreatments).toHaveBeenCalledTimes(5); // when ready from cache x2, ready x2 and update x1 + expect(user2Client.getTreatments).toHaveBeenLastCalledWith(['split_test'], undefined, undefined); + }); + + test('ignores flagSets if both names and flagSets params are provided.', () => { + render( + + { + React.createElement(() => { + // @ts-expect-error names and flagSets are mutually exclusive + const treatments = useTreatments({ names: featureFlagNames, flagSets, attributes }).treatments; + expect(treatments).toEqual({ split1: CONTROL }); + return null; + }) + } + + ); + }); + +}); diff --git a/src/__tests__/useSplitTreatments.test.tsx b/src/__tests__/useTreatmentsWithConfig.test.tsx similarity index 77% rename from src/__tests__/useSplitTreatments.test.tsx rename to src/__tests__/useTreatmentsWithConfig.test.tsx index bf32348..84b9981 100644 --- a/src/__tests__/useSplitTreatments.test.tsx +++ b/src/__tests__/useTreatmentsWithConfig.test.tsx @@ -12,11 +12,11 @@ import { CONTROL_WITH_CONFIG, EXCEPTION_NO_SFP } from '../constants'; /** Test target */ import { SplitFactoryProvider } from '../SplitFactoryProvider'; -import { useSplitTreatments } from '../useSplitTreatments'; +import { useTreatmentsWithConfig } from '../useTreatmentsWithConfig'; import { SplitContext } from '../SplitContext'; -import { ISplitTreatmentsChildProps } from '../types'; +import { IUseTreatmentsWithConfigResult } from '../types'; -describe('useSplitTreatments', () => { +describe('useTreatmentsWithConfig', () => { const featureFlagNames = ['split1']; const flagSets = ['set1']; @@ -32,11 +32,11 @@ describe('useSplitTreatments', () => { render( {React.createElement(() => { - treatments = useSplitTreatments({ names: featureFlagNames, attributes, properties }).treatments; - treatmentsByFlagSets = useSplitTreatments({ flagSets, attributes, properties }).treatments; + treatments = useTreatmentsWithConfig({ names: featureFlagNames, attributes, properties }).treatments; + treatmentsByFlagSets = useTreatmentsWithConfig({ flagSets, attributes, properties }).treatments; // @ts-expect-error Options object must provide either names or flagSets - expect(useSplitTreatments({}).treatments).toEqual({}); + expect(useTreatmentsWithConfig({}).treatments).toEqual({}); return null; })} @@ -68,7 +68,7 @@ describe('useSplitTreatments', () => { render( {React.createElement(() => { - const treatments = useSplitTreatments({ names: featureFlagNames, attributes, properties, splitKey: 'user2', updateOnSdkUpdate: false }).treatments; + const treatments = useTreatmentsWithConfig({ names: featureFlagNames, attributes, properties, splitKey: 'user2', updateOnSdkUpdate: false }).treatments; renderTimes++; switch (renderTimes) { @@ -102,8 +102,8 @@ describe('useSplitTreatments', () => { expect(() => { render( React.createElement(() => { - useSplitTreatments({ names: featureFlagNames, attributes }).treatments; - useSplitTreatments({ flagSets: featureFlagNames }).treatments; + useTreatmentsWithConfig({ names: featureFlagNames, attributes }).treatments; + useTreatmentsWithConfig({ flagSets: featureFlagNames }).treatments; return null; }) ); @@ -119,10 +119,10 @@ describe('useSplitTreatments', () => { { React.createElement(() => { // @ts-expect-error Test error handling - let treatments = useSplitTreatments('split1').treatments; + let treatments = useTreatmentsWithConfig('split1').treatments; expect(treatments).toEqual({}); // @ts-expect-error Test error handling - treatments = useSplitTreatments({ names: [true, ' flag_1 ', ' '] }).treatments; + treatments = useTreatmentsWithConfig({ names: [true, ' flag_1 ', ' '] }).treatments; expect(treatments).toEqual({ flag_1: CONTROL_WITH_CONFIG }); return null; @@ -132,16 +132,16 @@ describe('useSplitTreatments', () => { ); }); - test('useSplitTreatments must update on SDK events', async () => { + test('useTreatmentsWithConfig must update on SDK events', async () => { const outerFactory = SplitFactory(sdkBrowser); const mainClient = outerFactory.client() as any; const user2Client = outerFactory.client('user_2') as any; - let countSplitContext = 0, countUseSplitTreatments = 0, countUseSplitTreatmentsUser2 = 0, countUseSplitTreatmentsUser2WithoutUpdate = 0; + let countSplitContext = 0, countUseTreatmentsWithConfig = 0, countUseTreatmentsWithConfigUser2 = 0, countUseTreatmentsWithConfigUser2WithoutUpdate = 0; const lastUpdateSetUser2 = new Set(); const lastUpdateSetUser2WithUpdate = new Set(); - function validateTreatments({ treatments, isReady, isReadyFromCache }: ISplitTreatmentsChildProps) { + function validateTreatments({ treatments, isReady, isReadyFromCache }: IUseTreatmentsWithConfigResult) { if (isReady || isReadyFromCache) { expect(treatments).toEqual({ split_test: { @@ -166,26 +166,26 @@ describe('useSplitTreatments', () => { {() => countSplitContext++} {React.createElement(() => { - const context = useSplitTreatments({ names: ['split_test'], attributes: { att1: 'att1' } }); + const context = useTreatmentsWithConfig({ names: ['split_test'], attributes: { att1: 'att1' } }); expect(context.client).toBe(mainClient); // Assert that the main client was retrieved. validateTreatments(context); - countUseSplitTreatments++; + countUseTreatmentsWithConfig++; return null; })} {React.createElement(() => { - const context = useSplitTreatments({ names: ['split_test'], splitKey: 'user_2' }); + const context = useTreatmentsWithConfig({ names: ['split_test'], splitKey: 'user_2' }); expect(context.client).toBe(user2Client); validateTreatments(context); lastUpdateSetUser2.add(context.lastUpdate); - countUseSplitTreatmentsUser2++; + countUseTreatmentsWithConfigUser2++; return null; })} {React.createElement(() => { - const context = useSplitTreatments({ names: ['split_test'], splitKey: 'user_2', updateOnSdkUpdate: false }); + const context = useTreatmentsWithConfig({ names: ['split_test'], splitKey: 'user_2', updateOnSdkUpdate: false }); expect(context.client).toBe(user2Client); validateTreatments(context); lastUpdateSetUser2WithUpdate.add(context.lastUpdate); - countUseSplitTreatmentsUser2WithoutUpdate++; + countUseTreatmentsWithConfigUser2WithoutUpdate++; return null; })} @@ -202,16 +202,16 @@ describe('useSplitTreatments', () => { // SplitFactoryProvider renders once expect(countSplitContext).toEqual(1); - // If useSplitTreatments evaluates with the main client and have default update options, it re-renders for each main client event. - expect(countUseSplitTreatments).toEqual(4); + // If useTreatmentsWithConfig evaluates with the main client and have default update options, it re-renders for each main client event. + expect(countUseTreatmentsWithConfig).toEqual(4); expect(mainClient.getTreatmentsWithConfig).toHaveBeenCalledTimes(3); // when ready from cache, ready and update expect(mainClient.getTreatmentsWithConfig).toHaveBeenLastCalledWith(['split_test'], { att1: 'att1' }, undefined); - // If useSplitTreatments evaluates with a different client and have default update options, it re-renders for each event of the new client. - expect(countUseSplitTreatmentsUser2).toEqual(4); + // If useTreatmentsWithConfig evaluates with a different client and have default update options, it re-renders for each event of the new client. + expect(countUseTreatmentsWithConfigUser2).toEqual(4); expect(lastUpdateSetUser2.size).toEqual(4); // If it is used with `updateOnSdkUpdate: false`, it doesn't render when the client emits an SDK_UPDATE event. - expect(countUseSplitTreatmentsUser2WithoutUpdate).toEqual(3); + expect(countUseTreatmentsWithConfigUser2WithoutUpdate).toEqual(3); expect(lastUpdateSetUser2WithUpdate.size).toEqual(3); expect(user2Client.getTreatmentsWithConfig).toHaveBeenCalledTimes(5); // when ready from cache x2, ready x2 and update x1 expect(user2Client.getTreatmentsWithConfig).toHaveBeenLastCalledWith(['split_test'], undefined, undefined); @@ -223,7 +223,7 @@ describe('useSplitTreatments', () => { { React.createElement(() => { // @ts-expect-error names and flagSets are mutually exclusive - const treatments = useSplitTreatments({ names: featureFlagNames, flagSets, attributes }).treatments; + const treatments = useTreatmentsWithConfig({ names: featureFlagNames, flagSets, attributes }).treatments; expect(treatments).toEqual({ split1: CONTROL_WITH_CONFIG }); return null; }) From 709a35b72e3cda49dd25edc475bfbe3ff759d787 Mon Sep 17 00:00:00 2001 From: Emiliano Sanchez Date: Mon, 27 Oct 2025 14:50:16 -0300 Subject: [PATCH 3/7] Update comments and metadata --- README.md | 16 ++++++++-------- src/SplitClient.tsx | 2 -- src/SplitTreatments.tsx | 2 +- src/withSplitTreatments.tsx | 2 +- 4 files changed, 10 insertions(+), 12 deletions(-) diff --git a/README.md b/README.md index 30aec4c..987df64 100644 --- a/README.md +++ b/README.md @@ -18,7 +18,7 @@ Below is a simple example that describes the instantiation and most basic usage import React from 'react'; // Import SDK functions -import { SplitFactoryProvider, useSplitTreatments } from '@splitsoftware/splitio-react'; +import { SplitFactoryProvider, useTreatment } from '@splitsoftware/splitio-react'; // Define your config object const CONFIG = { @@ -29,18 +29,18 @@ const CONFIG = { }; function MyComponent() { - // Evaluate feature flags with useSplitTreatments hook - const { treatments: { FEATURE_FLAG_NAME }, isReady } = useSplitTreatments({ names: ['FEATURE_FLAG_NAME'] }); + // Evaluate a feature flag with useTreatment hook + const { treatment, isReady } = useTreatment({ name: 'FEATURE_FLAG_NAME' }); // Check SDK readiness using isReady prop if (!isReady) return
Loading SDK ...
; - if (FEATURE_FLAG_NAME.treatment === 'on') { - // return JSX for on treatment - } else if (FEATURE_FLAG_NAME.treatment === 'off') { - // return JSX for off treatment + if (treatment === 'on') { + // return JSX for 'on' treatment + } else if (treatment === 'off') { + // return JSX for 'off' treatment } else { - // return JSX for control treatment + // return JSX for 'control' treatment }; } diff --git a/src/SplitClient.tsx b/src/SplitClient.tsx index fd146ae..e94984d 100644 --- a/src/SplitClient.tsx +++ b/src/SplitClient.tsx @@ -9,8 +9,6 @@ import { useSplitClient } from './useSplitClient'; * * The underlying SDK client can be changed during the component lifecycle * if the component is updated with a different splitKey prop. - * - * @deprecated `SplitClient` will be removed in a future major release. We recommend replacing it with the `useSplitClient` hook. */ export function SplitClient(props: ISplitClientProps) { const { children } = props; diff --git a/src/SplitTreatments.tsx b/src/SplitTreatments.tsx index 7f52776..e895f72 100644 --- a/src/SplitTreatments.tsx +++ b/src/SplitTreatments.tsx @@ -9,7 +9,7 @@ import { useSplitTreatments } from './useSplitTreatments'; * call the 'client.getTreatmentsWithConfig()' method if the `names` prop is provided, or the 'client.getTreatmentsWithConfigByFlagSets()' method * if the `flagSets` prop is provided. It then passes the resulting treatments to a child component as a function. * - * @deprecated `SplitTreatments` will be removed in a future major release. We recommend replacing it with the `useSplitTreatments` hook. + * @deprecated `SplitTreatments` will be removed in a future major release. We recommend replacing it with the `useTreatment*` hooks. */ export function SplitTreatments(props: ISplitTreatmentsProps) { const { children } = props; diff --git a/src/withSplitTreatments.tsx b/src/withSplitTreatments.tsx index 8119292..54bf4fa 100644 --- a/src/withSplitTreatments.tsx +++ b/src/withSplitTreatments.tsx @@ -10,7 +10,7 @@ import { SplitTreatments } from './SplitTreatments'; * @param names - list of feature flag names * @param attributes - An object of type Attributes used to evaluate the feature flags. * - * @deprecated `withSplitTreatments` will be removed in a future major release. We recommend replacing it with the `useSplitTreatments` hook. + * @deprecated `withSplitTreatments` will be removed in a future major release. We recommend replacing it with the `useTreatment*` hooks. */ export function withSplitTreatments(names: string[], attributes?: SplitIO.Attributes) { From 284d1ee0435321ba32c77e9f4c6f1e0186275b07 Mon Sep 17 00:00:00 2001 From: Emiliano Sanchez Date: Thu, 30 Oct 2025 15:45:09 -0300 Subject: [PATCH 4/7] feat: add support for fallback treatments when client is not operational --- .github/workflows/ci-cd.yml | 6 ++--- package-lock.json | 34 ++++++++++++------------ package.json | 4 +-- src/useTreatment.ts | 4 +-- src/useTreatmentWithConfig.ts | 4 +-- src/useTreatments.ts | 4 +-- src/useTreatmentsWithConfig.ts | 4 +-- src/utils.ts | 47 ++++++++++++++++++++++++---------- 8 files changed, 63 insertions(+), 44 deletions(-) diff --git a/.github/workflows/ci-cd.yml b/.github/workflows/ci-cd.yml index 0d1ac13..172ec6c 100644 --- a/.github/workflows/ci-cd.yml +++ b/.github/workflows/ci-cd.yml @@ -49,7 +49,7 @@ jobs: run: echo "VERSION=$(cat package.json | jq -r .version)" >> $GITHUB_ENV - name: SonarQube Scan (Push) - if: github.event_name == 'push' && (github.ref == 'refs/heads/main' || github.ref == 'refs/heads/development') + if: github.event_name == 'push' && (github.ref == 'refs/heads/main' || github.ref == 'refs/heads/fallback-treatment-support') uses: SonarSource/sonarcloud-github-action@v1.9 env: SONAR_TOKEN: ${{ secrets.SONARQUBE_TOKEN }} @@ -77,7 +77,7 @@ jobs: -Dsonar.pullrequest.base=${{ github.event.pull_request.base.ref }} - name: Store assets - if: github.event_name == 'push' && (github.ref == 'refs/heads/main' || github.ref == 'refs/heads/development') + if: github.event_name == 'push' && (github.ref == 'refs/heads/main' || github.ref == 'refs/heads/fallback-treatment-support') uses: actions/upload-artifact@v4 with: name: assets @@ -88,7 +88,7 @@ jobs: name: Upload assets runs-on: ubuntu-latest needs: build - if: github.event_name == 'push' && github.ref == 'refs/heads/development' + if: github.event_name == 'push' && github.ref == 'refs/heads/fallback-treatment-support' strategy: matrix: environment: diff --git a/package-lock.json b/package-lock.json index 310bfe0..608697b 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,15 +1,15 @@ { "name": "@splitsoftware/splitio-react", - "version": "2.5.0", + "version": "2.5.1-rc.0", "lockfileVersion": 2, "requires": true, "packages": { "": { "name": "@splitsoftware/splitio-react", - "version": "2.5.0", + "version": "2.5.1-rc.0", "license": "Apache-2.0", "dependencies": { - "@splitsoftware/splitio": "11.7.2-rc.4", + "@splitsoftware/splitio": "11.7.2-rc.5", "memoize-one": "^5.1.1", "shallowequal": "^1.1.0", "tslib": "^2.3.1" @@ -1583,12 +1583,12 @@ } }, "node_modules/@splitsoftware/splitio": { - "version": "11.7.2-rc.4", - "resolved": "https://registry.npmjs.org/@splitsoftware/splitio/-/splitio-11.7.2-rc.4.tgz", - "integrity": "sha512-1A26oJ82JLmBC4OhRJoNgrESVCN+HErqrduiI3J88oznxIPjRVQGJt/BhfF87FhNXOXvtgx+4dF15Pje6zCq7A==", + "version": "11.7.2-rc.5", + "resolved": "https://registry.npmjs.org/@splitsoftware/splitio/-/splitio-11.7.2-rc.5.tgz", + "integrity": "sha512-wRg6/A5QK6uqM/Jmihz7kL4LxqYM04frv6CD6WaepPwggVz5Yjs4Aj08nOs2Q7KJ3Unn2tNodt9IuT6XWUcGQQ==", "license": "Apache-2.0", "dependencies": { - "@splitsoftware/splitio-commons": "2.7.9-rc.2", + "@splitsoftware/splitio-commons": "2.7.9-rc.3", "bloom-filters": "^3.0.4", "ioredis": "^4.28.0", "js-yaml": "^3.13.1", @@ -1601,9 +1601,9 @@ } }, "node_modules/@splitsoftware/splitio-commons": { - "version": "2.7.9-rc.2", - "resolved": "https://registry.npmjs.org/@splitsoftware/splitio-commons/-/splitio-commons-2.7.9-rc.2.tgz", - "integrity": "sha512-t8YVwDe4UBvD95w+mvKq7Z2khozZXDrIuOWt3ixxtmyeyoZp5L0L9x9E3DWOcQ0EVxfpQv+tAErHG3bw3LkbNg==", + "version": "2.7.9-rc.3", + "resolved": "https://registry.npmjs.org/@splitsoftware/splitio-commons/-/splitio-commons-2.7.9-rc.3.tgz", + "integrity": "sha512-momlpLuBt0yQXzo7blDWbNIs+H0fIPcxWukZVXMIKHiLiZtfu608diLT8EB/PNtA245OUMIRzachk5If4BBOWw==", "license": "Apache-2.0", "dependencies": { "@types/ioredis": "^4.28.0", @@ -12095,11 +12095,11 @@ } }, "@splitsoftware/splitio": { - "version": "11.7.2-rc.4", - "resolved": "https://registry.npmjs.org/@splitsoftware/splitio/-/splitio-11.7.2-rc.4.tgz", - "integrity": "sha512-1A26oJ82JLmBC4OhRJoNgrESVCN+HErqrduiI3J88oznxIPjRVQGJt/BhfF87FhNXOXvtgx+4dF15Pje6zCq7A==", + "version": "11.7.2-rc.5", + "resolved": "https://registry.npmjs.org/@splitsoftware/splitio/-/splitio-11.7.2-rc.5.tgz", + "integrity": "sha512-wRg6/A5QK6uqM/Jmihz7kL4LxqYM04frv6CD6WaepPwggVz5Yjs4Aj08nOs2Q7KJ3Unn2tNodt9IuT6XWUcGQQ==", "requires": { - "@splitsoftware/splitio-commons": "2.7.9-rc.2", + "@splitsoftware/splitio-commons": "2.7.9-rc.3", "bloom-filters": "^3.0.4", "ioredis": "^4.28.0", "js-yaml": "^3.13.1", @@ -12109,9 +12109,9 @@ } }, "@splitsoftware/splitio-commons": { - "version": "2.7.9-rc.2", - "resolved": "https://registry.npmjs.org/@splitsoftware/splitio-commons/-/splitio-commons-2.7.9-rc.2.tgz", - "integrity": "sha512-t8YVwDe4UBvD95w+mvKq7Z2khozZXDrIuOWt3ixxtmyeyoZp5L0L9x9E3DWOcQ0EVxfpQv+tAErHG3bw3LkbNg==", + "version": "2.7.9-rc.3", + "resolved": "https://registry.npmjs.org/@splitsoftware/splitio-commons/-/splitio-commons-2.7.9-rc.3.tgz", + "integrity": "sha512-momlpLuBt0yQXzo7blDWbNIs+H0fIPcxWukZVXMIKHiLiZtfu608diLT8EB/PNtA245OUMIRzachk5If4BBOWw==", "requires": { "@types/ioredis": "^4.28.0", "tslib": "^2.3.1" diff --git a/package.json b/package.json index 4c8e21c..7cf8a33 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "@splitsoftware/splitio-react", - "version": "2.5.0", + "version": "2.5.1-rc.0", "description": "A React library to easily integrate and use Split JS SDK", "main": "cjs/index.js", "module": "esm/index.js", @@ -63,7 +63,7 @@ }, "homepage": "https://github.com/splitio/react-client#readme", "dependencies": { - "@splitsoftware/splitio": "11.7.2-rc.4", + "@splitsoftware/splitio": "11.7.2-rc.5", "memoize-one": "^5.1.1", "shallowequal": "^1.1.0", "tslib": "^2.3.1" diff --git a/src/useTreatment.ts b/src/useTreatment.ts index d41438d..1e743e0 100644 --- a/src/useTreatment.ts +++ b/src/useTreatment.ts @@ -19,14 +19,14 @@ import { useSplitClient } from './useSplitClient'; */ export function useTreatment(options: IUseTreatmentOptions): IUseTreatmentResult { const context = useSplitClient({ ...options, attributes: undefined }); - const { client, lastUpdate } = context; + const { factory, client, lastUpdate } = context; const { name, attributes, properties } = options; const getTreatment = React.useMemo(memoizeGetTreatment, []); // Shallow copy `client.getAttributes` result for memoization, as it returns the same reference unless `client.clearAttributes` is invoked. // Note: the same issue occurs with the `names` and `attributes` arguments if they are mutated directly by the user instead of providing a new object. - const treatment = getTreatment(client, lastUpdate, [name], attributes, client ? { ...client.getAttributes() } : {}, undefined, properties && { properties }); + const treatment = getTreatment(client, lastUpdate, [name], attributes, client ? { ...client.getAttributes() } : {}, undefined, properties && { properties }, factory); return { ...context, diff --git a/src/useTreatmentWithConfig.ts b/src/useTreatmentWithConfig.ts index 819396b..b7790d3 100644 --- a/src/useTreatmentWithConfig.ts +++ b/src/useTreatmentWithConfig.ts @@ -19,14 +19,14 @@ import { useSplitClient } from './useSplitClient'; */ export function useTreatmentWithConfig(options: IUseTreatmentOptions): IUseTreatmentWithConfigResult { const context = useSplitClient({ ...options, attributes: undefined }); - const { client, lastUpdate } = context; + const { factory, client, lastUpdate } = context; const { name, attributes, properties } = options; const getTreatmentWithConfig = React.useMemo(memoizeGetTreatmentWithConfig, []); // Shallow copy `client.getAttributes` result for memoization, as it returns the same reference unless `client.clearAttributes` is invoked. // Note: the same issue occurs with the `names` and `attributes` arguments if they are mutated directly by the user instead of providing a new object. - const treatment = getTreatmentWithConfig(client, lastUpdate, [name], attributes, client ? { ...client.getAttributes() } : {}, undefined, properties && { properties }); + const treatment = getTreatmentWithConfig(client, lastUpdate, [name], attributes, client ? { ...client.getAttributes() } : {}, undefined, properties && { properties }, factory); return { ...context, diff --git a/src/useTreatments.ts b/src/useTreatments.ts index e90cb4e..45c29a9 100644 --- a/src/useTreatments.ts +++ b/src/useTreatments.ts @@ -20,14 +20,14 @@ import { useSplitClient } from './useSplitClient'; */ export function useTreatments(options: IUseTreatmentsOptions): IUseTreatmentsResult { const context = useSplitClient({ ...options, attributes: undefined }); - const { client, lastUpdate } = context; + const { factory, client, lastUpdate } = context; const { names, flagSets, attributes, properties } = options; const getTreatments = React.useMemo(memoizeGetTreatments, []); // Shallow copy `client.getAttributes` result for memoization, as it returns the same reference unless `client.clearAttributes` is invoked. // Note: the same issue occurs with the `names` and `attributes` arguments if they are mutated directly by the user instead of providing a new object. - const treatments = getTreatments(client, lastUpdate, names, attributes, client ? { ...client.getAttributes() } : {}, flagSets, properties && { properties }); + const treatments = getTreatments(client, lastUpdate, names, attributes, client ? { ...client.getAttributes() } : {}, flagSets, properties && { properties }, factory); return { ...context, diff --git a/src/useTreatmentsWithConfig.ts b/src/useTreatmentsWithConfig.ts index f59fe8d..0efb094 100644 --- a/src/useTreatmentsWithConfig.ts +++ b/src/useTreatmentsWithConfig.ts @@ -20,14 +20,14 @@ import { useSplitClient } from './useSplitClient'; */ export function useTreatmentsWithConfig(options: IUseTreatmentsOptions): IUseTreatmentsWithConfigResult { const context = useSplitClient({ ...options, attributes: undefined }); - const { client, lastUpdate } = context; + const { factory, client, lastUpdate } = context; const { names, flagSets, attributes, properties } = options; const getTreatmentsWithConfig = React.useMemo(memoizeGetTreatmentsWithConfig, []); // Shallow copy `client.getAttributes` result for memoization, as it returns the same reference unless `client.clearAttributes` is invoked. // Note: the same issue occurs with the `names` and `attributes` arguments if they are mutated directly by the user instead of providing a new object. - const treatments = getTreatmentsWithConfig(client, lastUpdate, names, attributes, client ? { ...client.getAttributes() } : {}, flagSets, properties && { properties }); + const treatments = getTreatmentsWithConfig(client, lastUpdate, names, attributes, client ? { ...client.getAttributes() } : {}, flagSets, properties && { properties }, factory); return { ...context, diff --git a/src/utils.ts b/src/utils.ts index 025599b..8db4e79 100644 --- a/src/utils.ts +++ b/src/utils.ts @@ -37,6 +37,24 @@ export function getStatus(client?: SplitIO.IBrowserClient): ISplitStatus { }; } +function resolveFallback(flagName: string, withConfig: true, factory?: SplitIO.IBrowserSDK): SplitIO.TreatmentWithConfig; +function resolveFallback(flagName: string, withConfig: false, factory?: SplitIO.IBrowserSDK): SplitIO.Treatment; +function resolveFallback(flagName: string, withConfig: boolean, factory?: SplitIO.IBrowserSDK): SplitIO.Treatment | SplitIO.TreatmentWithConfig { + if (factory && factory.settings.fallbackTreatments) { + const fallbacks = factory.settings.fallbackTreatments; + + const treatment = fallbacks.byFlag?.[flagName] || fallbacks.global; + + if (treatment) { + return isString(treatment) ? + withConfig ? { treatment, config: null } : treatment : + withConfig ? treatment : treatment.treatment; + } + } + + return withConfig ? CONTROL_WITH_CONFIG : CONTROL; +} + /** * Manage client attributes binding */ @@ -45,9 +63,9 @@ export function initAttributes(client?: SplitIO.IBrowserClient, attributes?: Spl if (client && attributes) client.setAttributes(attributes); } -export function getControlTreatments(featureFlagNames: unknown, withConfig: true): SplitIO.TreatmentsWithConfig; -export function getControlTreatments(featureFlagNames: unknown, withConfig: false): SplitIO.Treatments; -export function getControlTreatments(featureFlagNames: unknown, withConfig: boolean): SplitIO.Treatments | SplitIO.TreatmentsWithConfig { +export function getControlTreatments(featureFlagNames: unknown, withConfig: true, factory?: SplitIO.IBrowserSDK): SplitIO.TreatmentsWithConfig; +export function getControlTreatments(featureFlagNames: unknown, withConfig: false, factory?: SplitIO.IBrowserSDK): SplitIO.Treatments; +export function getControlTreatments(featureFlagNames: unknown, withConfig: boolean, factory?: SplitIO.IBrowserSDK): SplitIO.Treatments | SplitIO.TreatmentsWithConfig { // validate feature flag names if (!Array.isArray(featureFlagNames)) return {}; @@ -56,9 +74,10 @@ export function getControlTreatments(featureFlagNames: unknown, withConfig: bool .map((featureFlagName) => featureFlagName.trim()) .filter((featureFlagName) => featureFlagName.length > 0); - // return control treatments for each validated feature flag name - return (featureFlagNames as string[]).reduce((pValue: SplitIO.Treatments | SplitIO.TreatmentsWithConfig, cValue: string) => { - pValue[cValue] = withConfig ? CONTROL_WITH_CONFIG : CONTROL; + // return control or fallback treatment for each validated feature flag name + return (featureFlagNames as string[]).reduce((pValue: SplitIO.Treatments | SplitIO.TreatmentsWithConfig, featureFlagName: string) => { + // @ts-expect-error asd + pValue[featureFlagName] = resolveFallback(featureFlagName, withConfig, factory); return pValue; }, {}); } @@ -79,13 +98,13 @@ function argsAreEqual(newArgs: any[], lastArgs: any[]): boolean { shallowEqual(newArgs[5], lastArgs[5]); // flagSets } -function evaluateFeatureFlagsWithConfig(client: SplitIO.IBrowserClient | undefined, _lastUpdate: number, names?: SplitIO.SplitNames, attributes?: SplitIO.Attributes, _clientAttributes?: SplitIO.Attributes, flagSets?: string[], options?: SplitIO.EvaluationOptions) { +function evaluateFeatureFlagsWithConfig(client: SplitIO.IBrowserClient | undefined, _lastUpdate: number, names?: SplitIO.SplitNames, attributes?: SplitIO.Attributes, _clientAttributes?: SplitIO.Attributes, flagSets?: string[], options?: SplitIO.EvaluationOptions, factory?: SplitIO.IBrowserSDK) { return client && client.getStatus().isOperational && (names || flagSets) ? names ? client.getTreatmentsWithConfig(names, attributes, options) : client.getTreatmentsWithConfigByFlagSets(flagSets!, attributes, options) : names ? - getControlTreatments(names, true) : + getControlTreatments(names, true, factory) : {} // empty object when evaluating with flag sets and client is not ready } @@ -97,13 +116,13 @@ export function memoizeGetTreatmentsWithConfig() { return memoizeOne(evaluateFeatureFlagsWithConfig, argsAreEqual); } -function evaluateFeatureFlags(client: SplitIO.IBrowserClient | undefined, _lastUpdate: number, names?: SplitIO.SplitNames, attributes?: SplitIO.Attributes, _clientAttributes?: SplitIO.Attributes, flagSets?: string[], options?: SplitIO.EvaluationOptions) { +function evaluateFeatureFlags(client: SplitIO.IBrowserClient | undefined, _lastUpdate: number, names?: SplitIO.SplitNames, attributes?: SplitIO.Attributes, _clientAttributes?: SplitIO.Attributes, flagSets?: string[], options?: SplitIO.EvaluationOptions, factory?: SplitIO.IBrowserSDK) { return client && client.getStatus().isOperational && (names || flagSets) ? names ? client.getTreatments(names, attributes, options) : client.getTreatmentsByFlagSets(flagSets!, attributes, options) : names ? - getControlTreatments(names, false) : + getControlTreatments(names, false, factory) : {} // empty object when evaluating with flag sets and client is not ready } @@ -111,20 +130,20 @@ export function memoizeGetTreatments() { return memoizeOne(evaluateFeatureFlags, argsAreEqual); } -function evaluateFeatureFlagWithConfig(client: SplitIO.IBrowserClient | undefined, _lastUpdate: number, names: string[], attributes?: SplitIO.Attributes, _clientAttributes?: SplitIO.Attributes, _flagSets?: undefined, options?: SplitIO.EvaluationOptions) { +function evaluateFeatureFlagWithConfig(client: SplitIO.IBrowserClient | undefined, _lastUpdate: number, names: string[], attributes?: SplitIO.Attributes, _clientAttributes?: SplitIO.Attributes, _flagSets?: undefined, options?: SplitIO.EvaluationOptions, factory?: SplitIO.IBrowserSDK) { return client && client.getStatus().isOperational ? client.getTreatmentWithConfig(names[0], attributes, options) : - CONTROL_WITH_CONFIG + resolveFallback(names[0], true, factory); } export function memoizeGetTreatmentWithConfig() { return memoizeOne(evaluateFeatureFlagWithConfig, argsAreEqual); } -function evaluateFeatureFlag(client: SplitIO.IBrowserClient | undefined, _lastUpdate: number, names: string[], attributes?: SplitIO.Attributes, _clientAttributes?: SplitIO.Attributes, _flagSets?: undefined, options?: SplitIO.EvaluationOptions) { +function evaluateFeatureFlag(client: SplitIO.IBrowserClient | undefined, _lastUpdate: number, names: string[], attributes?: SplitIO.Attributes, _clientAttributes?: SplitIO.Attributes, _flagSets?: undefined, options?: SplitIO.EvaluationOptions, factory?: SplitIO.IBrowserSDK) { return client && client.getStatus().isOperational ? client.getTreatment(names[0], attributes, options) : - CONTROL; + resolveFallback(names[0], false, factory); } export function memoizeGetTreatment() { From 9a587811b1140b614223f5b9711bb2280b408f21 Mon Sep 17 00:00:00 2001 From: Emiliano Sanchez Date: Fri, 31 Oct 2025 16:52:57 -0300 Subject: [PATCH 5/7] docs: update migration guide with reintroduced useTreatments hook details --- MIGRATION-GUIDE.md | 35 +++++++++++++++++++++++++++++------ 1 file changed, 29 insertions(+), 6 deletions(-) diff --git a/MIGRATION-GUIDE.md b/MIGRATION-GUIDE.md index 1faac7d..d867951 100644 --- a/MIGRATION-GUIDE.md +++ b/MIGRATION-GUIDE.md @@ -3,9 +3,32 @@ React SDK v2.0.0 has a few breaking changes that you should consider when migrating from a previous version. The main changes are: -### • Deprecated `useClient`, `useTreatments`, and `useManager` hooks have been removed. -Follow [this section](#migrating-to-get-react-sdk-v1100-improvements-replacing-the-deprecated-useclient-usetreatments-and-usemanager-hooks) to migrate to the new hooks `useSplitClient`, `useSplitTreatments`, and `useSplitManager`. +### • `useTreatments` hook was removed in v2.0.0, but re-introduced in v2.6.0 with a different API: + +Since v2.6.0, there are 4 hooks variants to evaluate feature flags, to better cover the different evaluation methods available in the JavaScript SDK client: + +- `useTreatment`: returns a treatment value for a given feature flag name. It calls `client.getTreatment()` method under the hood. +- `useTreatmentWithConfig`: returns a single treatment value and its configuration for a given feature flag name. It calls `client.getTreatmentWithConfig()` method under the hood. +- `useTreatments`: returns an object with treatment values for multiple feature flag names. It calls `client.getTreatments()` or `client.getTreatmentsByFlagSets()` methods under the hood, depending if the `names` or `flagSets` option is provided. +- `useTreatmentsWithConfig`: returns an object with treatment values and their configurations for multiple feature flag names. It calls `client.getTreatmentsWithConfig()` or `client.getTreatmentsWithConfigByFlagSets()` methods under the hood, depending if the `names` or `flagSets` option is provided. + +The `useTreatments` hook from v1.x.x should be replaced with `useTreatmentsWithConfig`, as follows: + +```javascript +// v1.x.x +const treatments = useTreatments(featureFlagNames, optionalAttributes, optionalSplitKey); + +// v2.6.0+ +const { treatments } = useTreatmentsWithConfig({ names: featureFlagNames, attributes: optionalAttributes, splitKey: optionalSplitKey }); + +// v2.0.0-v2.5.0 +const { treatments } = useSplitTreatments({ names: featureFlagNames, attributes: optionalAttributes, splitKey: optionalSplitKey }); +``` + +### • Deprecated `useClient` and `useManager` hooks have been removed. + +Follow [this section](#migrating-to-get-react-sdk-v1100-improvements-replacing-the-deprecated-useclient-usetreatments-and-usemanager-hooks) to migrate to the new hooks `useSplitClient` and `useSplitManager`. ### • Updated the default value of `updateOnSdkUpdate` and `updateOnSdkTimedout` options to `true`. @@ -15,7 +38,7 @@ Consider setting the `updateOnSdkUpdate` option to `false` to revert to the prev The same applies for the equivalent props in the `[with]SplitClient` and `[with]SplitTreatments` components, although these components are deprecated and we recommend [migrating to their hook alternatives](#-high-order-components-withsplitclient-withsplittreatments-and-components-that-accept-a-render-function-as-child-component-splittreatments-and-splitclient-have-been-deprecated-and-might-be-removed-in-a-future-major-release). -### • Deprecated `SplitFactory` provider has been removed, `withSplitFactory` is deprecated, and `SplitFactoryProvider` doesn't accept `updateOn` props and a render function as children anymore. +### • Deprecated `SplitFactory` provider has been removed, `withSplitFactory` is deprecated, and `SplitFactoryProvider` doesn't accept a render function as children anymore. To migrate your existing code to the new version of `SplitFactoryProvider`, consider the following refactor example: @@ -53,21 +76,21 @@ should be refactored to: ```tsx const MyComponent = () => { - const props: ISplitContextValues = useSplitClient({ updateOnSdkUpdate: false }); + const props: ISplitContextValues = useSplitClient(); const { factory, client, isReady, isReadyFromCache, ... } = props; ... }; const App = () => { return ( - + ); }; ``` -Notice that `MyComponent` was refactored to use the `useSplitClient` hook and is passed as a React JSX element rather than a render function. The `useSplitClient` hook is called without providing a `splitKey` param. This means that the default client (whose key is set in the `core.key` property of the `mySplitConfig` object) will be used, and the `updateOnSdkUpdate` and `attributes` props are passed as options to the hook. +Notice that `MyComponent` was refactored to use the `useSplitClient` hook and is passed as a React JSX element rather than a render function. The `useSplitClient` hook is called without providing a `splitKey` param. This means that the default client (whose key is set in the `core.key` property of the `mySplitConfig` object) will be used. ### • High-Order-Components (`withSplitClient`, `withSplitTreatments`) and components that accept a render function as child component (`SplitTreatments`, and `SplitClient`) have been deprecated and might be removed in a future major release. From 54aaea6a4aec288d3373551ee03ecd6e10a9d0c1 Mon Sep 17 00:00:00 2001 From: Emiliano Sanchez Date: Mon, 3 Nov 2025 21:30:59 -0300 Subject: [PATCH 6/7] Unit tests --- .github/workflows/ci-cd.yml | 6 +-- CHANGES.txt | 2 +- package-lock.json | 46 +++++++++---------- package.json | 4 +- src/__tests__/testUtils/sdkConfigs.ts | 8 ++++ src/__tests__/useTreatment.test.tsx | 16 ++++++- src/__tests__/useTreatmentWithConfig.test.tsx | 16 ++++++- src/__tests__/useTreatments.test.tsx | 16 ++++++- .../useTreatmentsWithConfig.test.tsx | 16 ++++++- 9 files changed, 93 insertions(+), 37 deletions(-) diff --git a/.github/workflows/ci-cd.yml b/.github/workflows/ci-cd.yml index 172ec6c..0d1ac13 100644 --- a/.github/workflows/ci-cd.yml +++ b/.github/workflows/ci-cd.yml @@ -49,7 +49,7 @@ jobs: run: echo "VERSION=$(cat package.json | jq -r .version)" >> $GITHUB_ENV - name: SonarQube Scan (Push) - if: github.event_name == 'push' && (github.ref == 'refs/heads/main' || github.ref == 'refs/heads/fallback-treatment-support') + if: github.event_name == 'push' && (github.ref == 'refs/heads/main' || github.ref == 'refs/heads/development') uses: SonarSource/sonarcloud-github-action@v1.9 env: SONAR_TOKEN: ${{ secrets.SONARQUBE_TOKEN }} @@ -77,7 +77,7 @@ jobs: -Dsonar.pullrequest.base=${{ github.event.pull_request.base.ref }} - name: Store assets - if: github.event_name == 'push' && (github.ref == 'refs/heads/main' || github.ref == 'refs/heads/fallback-treatment-support') + if: github.event_name == 'push' && (github.ref == 'refs/heads/main' || github.ref == 'refs/heads/development') uses: actions/upload-artifact@v4 with: name: assets @@ -88,7 +88,7 @@ jobs: name: Upload assets runs-on: ubuntu-latest needs: build - if: github.event_name == 'push' && github.ref == 'refs/heads/fallback-treatment-support' + if: github.event_name == 'push' && github.ref == 'refs/heads/development' strategy: matrix: environment: diff --git a/CHANGES.txt b/CHANGES.txt index 83b116e..3be7e1c 100644 --- a/CHANGES.txt +++ b/CHANGES.txt @@ -1,4 +1,4 @@ -2.6.0 (October 31, 2025) +2.6.0 (November 4, 2025) - Added `useTreatment`, `useTreatments`, `useTreatmentWithConfig` and `useTreatmentsWithConfig` hooks to replace the now deprecated `useSplitTreatments` hook. - Updated @splitsoftware/splitio package to version 11.8.0 that includes minor updates: - Added new configuration for Fallback Treatments, which allows setting a treatment value and optional config to be returned in place of "control", either globally or by flag. Read more in our docs. diff --git a/package-lock.json b/package-lock.json index 608697b..d27eccf 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,15 +1,15 @@ { "name": "@splitsoftware/splitio-react", - "version": "2.5.1-rc.0", + "version": "2.6.0", "lockfileVersion": 2, "requires": true, "packages": { "": { "name": "@splitsoftware/splitio-react", - "version": "2.5.1-rc.0", + "version": "2.6.0", "license": "Apache-2.0", "dependencies": { - "@splitsoftware/splitio": "11.7.2-rc.5", + "@splitsoftware/splitio": "11.8.0", "memoize-one": "^5.1.1", "shallowequal": "^1.1.0", "tslib": "^2.3.1" @@ -778,9 +778,9 @@ "dev": true }, "node_modules/@ioredis/commands": { - "version": "1.4.0", - "resolved": "https://registry.npmjs.org/@ioredis/commands/-/commands-1.4.0.tgz", - "integrity": "sha512-aFT2yemJJo+TZCmieA7qnYGQooOS7QfNmYrzGtsYd3g9j5iDP8AimYYAesf79ohjbLG12XxC4nG5DyEnC88AsQ==", + "version": "1.5.0", + "resolved": "https://registry.npmjs.org/@ioredis/commands/-/commands-1.5.0.tgz", + "integrity": "sha512-eUgLqrMf8nJkZxT24JvVRrQya1vZkQh8BBeYNwGDqa5I0VUi8ACx7uFvAaLxintokpTenkK6DASvo/bvNbBGow==", "license": "MIT" }, "node_modules/@istanbuljs/load-nyc-config": { @@ -1583,12 +1583,12 @@ } }, "node_modules/@splitsoftware/splitio": { - "version": "11.7.2-rc.5", - "resolved": "https://registry.npmjs.org/@splitsoftware/splitio/-/splitio-11.7.2-rc.5.tgz", - "integrity": "sha512-wRg6/A5QK6uqM/Jmihz7kL4LxqYM04frv6CD6WaepPwggVz5Yjs4Aj08nOs2Q7KJ3Unn2tNodt9IuT6XWUcGQQ==", + "version": "11.8.0", + "resolved": "https://registry.npmjs.org/@splitsoftware/splitio/-/splitio-11.8.0.tgz", + "integrity": "sha512-M9ENeH+IEmxwELeCdXgnTbLg+ZP3SRUMM6lImSbv7mD32u1v6ihnUhnhsTJzlQWMDC4H94EAW345q1cO7ovlTQ==", "license": "Apache-2.0", "dependencies": { - "@splitsoftware/splitio-commons": "2.7.9-rc.3", + "@splitsoftware/splitio-commons": "2.8.0", "bloom-filters": "^3.0.4", "ioredis": "^4.28.0", "js-yaml": "^3.13.1", @@ -1601,9 +1601,9 @@ } }, "node_modules/@splitsoftware/splitio-commons": { - "version": "2.7.9-rc.3", - "resolved": "https://registry.npmjs.org/@splitsoftware/splitio-commons/-/splitio-commons-2.7.9-rc.3.tgz", - "integrity": "sha512-momlpLuBt0yQXzo7blDWbNIs+H0fIPcxWukZVXMIKHiLiZtfu608diLT8EB/PNtA245OUMIRzachk5If4BBOWw==", + "version": "2.8.0", + "resolved": "https://registry.npmjs.org/@splitsoftware/splitio-commons/-/splitio-commons-2.8.0.tgz", + "integrity": "sha512-QgHUreMOEDwf4GZzVPu4AzkZJvuaeSoHsiJc4tT3CxSIYl2bKMz1SSDlI1tW/oVbIFeWjkrIp2lCYEyUBgcvyA==", "license": "Apache-2.0", "dependencies": { "@types/ioredis": "^4.28.0", @@ -11463,9 +11463,9 @@ "dev": true }, "@ioredis/commands": { - "version": "1.4.0", - "resolved": "https://registry.npmjs.org/@ioredis/commands/-/commands-1.4.0.tgz", - "integrity": "sha512-aFT2yemJJo+TZCmieA7qnYGQooOS7QfNmYrzGtsYd3g9j5iDP8AimYYAesf79ohjbLG12XxC4nG5DyEnC88AsQ==" + "version": "1.5.0", + "resolved": "https://registry.npmjs.org/@ioredis/commands/-/commands-1.5.0.tgz", + "integrity": "sha512-eUgLqrMf8nJkZxT24JvVRrQya1vZkQh8BBeYNwGDqa5I0VUi8ACx7uFvAaLxintokpTenkK6DASvo/bvNbBGow==" }, "@istanbuljs/load-nyc-config": { "version": "1.1.0", @@ -12095,11 +12095,11 @@ } }, "@splitsoftware/splitio": { - "version": "11.7.2-rc.5", - "resolved": "https://registry.npmjs.org/@splitsoftware/splitio/-/splitio-11.7.2-rc.5.tgz", - "integrity": "sha512-wRg6/A5QK6uqM/Jmihz7kL4LxqYM04frv6CD6WaepPwggVz5Yjs4Aj08nOs2Q7KJ3Unn2tNodt9IuT6XWUcGQQ==", + "version": "11.8.0", + "resolved": "https://registry.npmjs.org/@splitsoftware/splitio/-/splitio-11.8.0.tgz", + "integrity": "sha512-M9ENeH+IEmxwELeCdXgnTbLg+ZP3SRUMM6lImSbv7mD32u1v6ihnUhnhsTJzlQWMDC4H94EAW345q1cO7ovlTQ==", "requires": { - "@splitsoftware/splitio-commons": "2.7.9-rc.3", + "@splitsoftware/splitio-commons": "2.8.0", "bloom-filters": "^3.0.4", "ioredis": "^4.28.0", "js-yaml": "^3.13.1", @@ -12109,9 +12109,9 @@ } }, "@splitsoftware/splitio-commons": { - "version": "2.7.9-rc.3", - "resolved": "https://registry.npmjs.org/@splitsoftware/splitio-commons/-/splitio-commons-2.7.9-rc.3.tgz", - "integrity": "sha512-momlpLuBt0yQXzo7blDWbNIs+H0fIPcxWukZVXMIKHiLiZtfu608diLT8EB/PNtA245OUMIRzachk5If4BBOWw==", + "version": "2.8.0", + "resolved": "https://registry.npmjs.org/@splitsoftware/splitio-commons/-/splitio-commons-2.8.0.tgz", + "integrity": "sha512-QgHUreMOEDwf4GZzVPu4AzkZJvuaeSoHsiJc4tT3CxSIYl2bKMz1SSDlI1tW/oVbIFeWjkrIp2lCYEyUBgcvyA==", "requires": { "@types/ioredis": "^4.28.0", "tslib": "^2.3.1" diff --git a/package.json b/package.json index 7cf8a33..86da7e5 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "@splitsoftware/splitio-react", - "version": "2.5.1-rc.0", + "version": "2.6.0", "description": "A React library to easily integrate and use Split JS SDK", "main": "cjs/index.js", "module": "esm/index.js", @@ -63,7 +63,7 @@ }, "homepage": "https://github.com/splitio/react-client#readme", "dependencies": { - "@splitsoftware/splitio": "11.7.2-rc.5", + "@splitsoftware/splitio": "11.8.0", "memoize-one": "^5.1.1", "shallowequal": "^1.1.0", "tslib": "^2.3.1" diff --git a/src/__tests__/testUtils/sdkConfigs.ts b/src/__tests__/testUtils/sdkConfigs.ts index 0ec97f5..b3397e6 100644 --- a/src/__tests__/testUtils/sdkConfigs.ts +++ b/src/__tests__/testUtils/sdkConfigs.ts @@ -4,3 +4,11 @@ export const sdkBrowser: SplitIO.IBrowserSettings = { key: 'customer-key', }, }; + +export const sdkBrowserWithConfig: SplitIO.IBrowserSettings = { + ...sdkBrowser, + fallbackTreatments: { + global: 'control_global', + byFlag: { ff1: { treatment: 'control_ff1', config: 'control_ff1_config' } } + } +}; diff --git a/src/__tests__/useTreatment.test.tsx b/src/__tests__/useTreatment.test.tsx index 106131e..69bde09 100644 --- a/src/__tests__/useTreatment.test.tsx +++ b/src/__tests__/useTreatment.test.tsx @@ -7,7 +7,7 @@ jest.mock('@splitsoftware/splitio/client', () => { return { SplitFactory: mockSdk() }; }); import { SplitFactory } from '@splitsoftware/splitio/client'; -import { sdkBrowser } from './testUtils/sdkConfigs'; +import { sdkBrowser, sdkBrowserWithConfig } from './testUtils/sdkConfigs'; import { CONTROL, EXCEPTION_NO_SFP } from '../constants'; /** Test target */ @@ -96,7 +96,7 @@ describe('useTreatment', () => { }).toThrow(EXCEPTION_NO_SFP); }); - test('useTreatment must update on SDK events', async () => { + test('must update on SDK events', async () => { const outerFactory = SplitFactory(sdkBrowser); const mainClient = outerFactory.client() as any; const user2Client = outerFactory.client('user_2') as any; @@ -171,4 +171,16 @@ describe('useTreatment', () => { expect(user2Client.getTreatment).toHaveBeenLastCalledWith('split_test', undefined, undefined); }); + test('returns fallback treatment if the client is not operational', () => { + render( + + {React.createElement(() => { + expect(useTreatment({ name: featureFlagName, attributes, properties }).treatment).toEqual('control_global'); + expect(useTreatment({ name: 'ff1', attributes, properties }).treatment).toEqual('control_ff1'); + return null; + })} + + ); + }); + }); diff --git a/src/__tests__/useTreatmentWithConfig.test.tsx b/src/__tests__/useTreatmentWithConfig.test.tsx index a296126..f74ac42 100644 --- a/src/__tests__/useTreatmentWithConfig.test.tsx +++ b/src/__tests__/useTreatmentWithConfig.test.tsx @@ -7,7 +7,7 @@ jest.mock('@splitsoftware/splitio/client', () => { return { SplitFactory: mockSdk() }; }); import { SplitFactory } from '@splitsoftware/splitio/client'; -import { sdkBrowser } from './testUtils/sdkConfigs'; +import { sdkBrowser, sdkBrowserWithConfig } from './testUtils/sdkConfigs'; import { CONTROL_WITH_CONFIG, EXCEPTION_NO_SFP } from '../constants'; /** Test target */ @@ -96,7 +96,7 @@ describe('useTreatmentWithConfig', () => { }).toThrow(EXCEPTION_NO_SFP); }); - test('useTreatmentWithConfig must update on SDK events', async () => { + test('must update on SDK events', async () => { const outerFactory = SplitFactory(sdkBrowser); const mainClient = outerFactory.client() as any; const user2Client = outerFactory.client('user_2') as any; @@ -171,4 +171,16 @@ describe('useTreatmentWithConfig', () => { expect(user2Client.getTreatmentWithConfig).toHaveBeenLastCalledWith('split_test', undefined, undefined); }); + test('returns fallback treatment if the client is not operational', () => { + render( + + {React.createElement(() => { + expect(useTreatmentWithConfig({ name: featureFlagName, attributes, properties }).treatment).toEqual({ treatment: 'control_global', config: null }); + expect(useTreatmentWithConfig({ name: 'ff1', attributes, properties }).treatment).toEqual({ treatment: 'control_ff1', config: 'control_ff1_config' }); + return null; + })} + + ); + }); + }); diff --git a/src/__tests__/useTreatments.test.tsx b/src/__tests__/useTreatments.test.tsx index 9b5bb57..98712b4 100644 --- a/src/__tests__/useTreatments.test.tsx +++ b/src/__tests__/useTreatments.test.tsx @@ -7,7 +7,7 @@ jest.mock('@splitsoftware/splitio/client', () => { return { SplitFactory: mockSdk() }; }); import { SplitFactory } from '@splitsoftware/splitio/client'; -import { sdkBrowser } from './testUtils/sdkConfigs'; +import { sdkBrowser, sdkBrowserWithConfig } from './testUtils/sdkConfigs'; import { CONTROL, EXCEPTION_NO_SFP } from '../constants'; /** Test target */ @@ -132,7 +132,7 @@ describe('useTreatments', () => { ); }); - test('useTreatments must update on SDK events', async () => { + test('must update on SDK events', async () => { const outerFactory = SplitFactory(sdkBrowser); const mainClient = outerFactory.client() as any; const user2Client = outerFactory.client('user_2') as any; @@ -226,4 +226,16 @@ describe('useTreatments', () => { ); }); + test('returns fallback treatments if the client is not operational', () => { + render( + + {React.createElement(() => { + const { treatments } = useTreatments({ names: ['ff1', 'ff2'], attributes, properties }); + expect(treatments).toEqual({ ff1: 'control_ff1', ff2: 'control_global' }); + return null; + })} + + ); + }); + }); diff --git a/src/__tests__/useTreatmentsWithConfig.test.tsx b/src/__tests__/useTreatmentsWithConfig.test.tsx index 84b9981..a8c5c0b 100644 --- a/src/__tests__/useTreatmentsWithConfig.test.tsx +++ b/src/__tests__/useTreatmentsWithConfig.test.tsx @@ -7,7 +7,7 @@ jest.mock('@splitsoftware/splitio/client', () => { return { SplitFactory: mockSdk() }; }); import { SplitFactory } from '@splitsoftware/splitio/client'; -import { sdkBrowser } from './testUtils/sdkConfigs'; +import { sdkBrowser, sdkBrowserWithConfig } from './testUtils/sdkConfigs'; import { CONTROL_WITH_CONFIG, EXCEPTION_NO_SFP } from '../constants'; /** Test target */ @@ -132,7 +132,7 @@ describe('useTreatmentsWithConfig', () => { ); }); - test('useTreatmentsWithConfig must update on SDK events', async () => { + test('must update on SDK events', async () => { const outerFactory = SplitFactory(sdkBrowser); const mainClient = outerFactory.client() as any; const user2Client = outerFactory.client('user_2') as any; @@ -232,4 +232,16 @@ describe('useTreatmentsWithConfig', () => { ); }); + test('returns fallback treatments if the client is not operational', () => { + render( + + {React.createElement(() => { + const { treatments } = useTreatmentsWithConfig({ names: ['ff1', 'ff2'], attributes, properties }); + expect(treatments).toEqual({ ff1: { treatment: 'control_ff1', config: 'control_ff1_config' }, ff2: { treatment: 'control_global', config: null } }); + return null; + })} + + ); + }); + }); From 202939cd32e006a40c7c979aefb604fefaa4d37a Mon Sep 17 00:00:00 2001 From: Emiliano Sanchez Date: Tue, 4 Nov 2025 11:42:12 -0300 Subject: [PATCH 7/7] refactor: reorganize utils.ts with better code structure and documentation --- MIGRATION-GUIDE.md | 2 +- src/utils.ts | 43 +++++++++++++++++-------------------------- 2 files changed, 18 insertions(+), 27 deletions(-) diff --git a/MIGRATION-GUIDE.md b/MIGRATION-GUIDE.md index d867951..2236d48 100644 --- a/MIGRATION-GUIDE.md +++ b/MIGRATION-GUIDE.md @@ -9,7 +9,7 @@ React SDK v2.0.0 has a few breaking changes that you should consider when migrat Since v2.6.0, there are 4 hooks variants to evaluate feature flags, to better cover the different evaluation methods available in the JavaScript SDK client: - `useTreatment`: returns a treatment value for a given feature flag name. It calls `client.getTreatment()` method under the hood. -- `useTreatmentWithConfig`: returns a single treatment value and its configuration for a given feature flag name. It calls `client.getTreatmentWithConfig()` method under the hood. +- `useTreatmentWithConfig`: returns a treatment value and its configuration for a given feature flag name. It calls `client.getTreatmentWithConfig()` method under the hood. - `useTreatments`: returns an object with treatment values for multiple feature flag names. It calls `client.getTreatments()` or `client.getTreatmentsByFlagSets()` methods under the hood, depending if the `names` or `flagSets` option is provided. - `useTreatmentsWithConfig`: returns an object with treatment values and their configurations for multiple feature flag names. It calls `client.getTreatmentsWithConfig()` or `client.getTreatmentsWithConfigByFlagSets()` methods under the hood, depending if the `names` or `flagSets` option is provided. diff --git a/src/utils.ts b/src/utils.ts index 8db4e79..8ccdfa2 100644 --- a/src/utils.ts +++ b/src/utils.ts @@ -3,14 +3,12 @@ import shallowEqual from 'shallowequal'; import { CONTROL, CONTROL_WITH_CONFIG } from './constants'; import { ISplitStatus } from './types'; -// Utils used to access singleton instances of Split factories and clients - -export interface IFactoryWithLazyInit extends SplitIO.IBrowserSDK { - config: SplitIO.IBrowserSettings; - init(): void; +function isString(val: unknown): val is string { + return typeof val === 'string' || val instanceof String; } -// idempotent operation +// Utils used to access singleton instances of Split factories and clients: + export function getSplitClient(factory: SplitIO.IBrowserSDK, key?: SplitIO.SplitKey): SplitIO.IBrowserClient { // factory.client is an idempotent operation const client = key !== undefined ? factory.client(key) : factory.client(); @@ -22,7 +20,6 @@ export function getSplitClient(factory: SplitIO.IBrowserSDK, key?: SplitIO.Split return client; } -// Util used to get client status. export function getStatus(client?: SplitIO.IBrowserClient): ISplitStatus { return client ? client.getStatus() : @@ -37,9 +34,18 @@ export function getStatus(client?: SplitIO.IBrowserClient): ISplitStatus { }; } +// Manage client attributes binding +// @TODO should reset attributes rather than set/merge them, to keep SFP and hooks pure. +export function initAttributes(client?: SplitIO.IBrowserClient, attributes?: SplitIO.Attributes) { + if (client && attributes) client.setAttributes(attributes); +} + +// Utils used to retrieve treatments when the client is not operational: + function resolveFallback(flagName: string, withConfig: true, factory?: SplitIO.IBrowserSDK): SplitIO.TreatmentWithConfig; function resolveFallback(flagName: string, withConfig: false, factory?: SplitIO.IBrowserSDK): SplitIO.Treatment; -function resolveFallback(flagName: string, withConfig: boolean, factory?: SplitIO.IBrowserSDK): SplitIO.Treatment | SplitIO.TreatmentWithConfig { +function resolveFallback(flagName: string, withConfig: boolean, factory?: SplitIO.IBrowserSDK): SplitIO.Treatment | SplitIO.TreatmentWithConfig; +function resolveFallback(flagName: string, withConfig: boolean, factory?: SplitIO.IBrowserSDK) { if (factory && factory.settings.fallbackTreatments) { const fallbacks = factory.settings.fallbackTreatments; @@ -55,17 +61,9 @@ function resolveFallback(flagName: string, withConfig: boolean, factory?: SplitI return withConfig ? CONTROL_WITH_CONFIG : CONTROL; } -/** - * Manage client attributes binding - */ -// @TODO should reset attributes rather than set/merge them, to keep SFP and hooks pure. -export function initAttributes(client?: SplitIO.IBrowserClient, attributes?: SplitIO.Attributes) { - if (client && attributes) client.setAttributes(attributes); -} - export function getControlTreatments(featureFlagNames: unknown, withConfig: true, factory?: SplitIO.IBrowserSDK): SplitIO.TreatmentsWithConfig; export function getControlTreatments(featureFlagNames: unknown, withConfig: false, factory?: SplitIO.IBrowserSDK): SplitIO.Treatments; -export function getControlTreatments(featureFlagNames: unknown, withConfig: boolean, factory?: SplitIO.IBrowserSDK): SplitIO.Treatments | SplitIO.TreatmentsWithConfig { +export function getControlTreatments(featureFlagNames: unknown, withConfig: boolean, factory?: SplitIO.IBrowserSDK) { // validate feature flag names if (!Array.isArray(featureFlagNames)) return {}; @@ -76,18 +74,15 @@ export function getControlTreatments(featureFlagNames: unknown, withConfig: bool // return control or fallback treatment for each validated feature flag name return (featureFlagNames as string[]).reduce((pValue: SplitIO.Treatments | SplitIO.TreatmentsWithConfig, featureFlagName: string) => { - // @ts-expect-error asd pValue[featureFlagName] = resolveFallback(featureFlagName, withConfig, factory); return pValue; }, {}); } /** - * Checks if a given value is a string. + * Utils to memoize `client.getTreatments*` method calls to avoid duplicated impressions. + * The result treatments are the same given the same `client` instance, `lastUpdate` timestamp, and list of feature flag names and attributes. */ -function isString(val: unknown): val is string { - return typeof val === 'string' || val instanceof String; -} function argsAreEqual(newArgs: any[], lastArgs: any[]): boolean { return newArgs[0] === lastArgs[0] && // client @@ -108,10 +103,6 @@ function evaluateFeatureFlagsWithConfig(client: SplitIO.IBrowserClient | undefin {} // empty object when evaluating with flag sets and client is not ready } -/** - * Gets a memoized version of the `client.getTreatmentsWithConfig` method. - * It is used to avoid duplicated impressions, because the result treatments are the same given the same `client` instance, `lastUpdate` timestamp, and list of feature flag `names` and `attributes`. - */ export function memoizeGetTreatmentsWithConfig() { return memoizeOne(evaluateFeatureFlagsWithConfig, argsAreEqual); }