diff --git a/CHANGES.txt b/CHANGES.txt index bd0383b..053cfd6 100644 --- a/CHANGES.txt +++ b/CHANGES.txt @@ -1,12 +1,15 @@ -1.10.0 (November XX, 2023) +1.10.0 (November 16, 2023) + - Added support for Flag Sets on the SDK, which enables grouping feature flags and interacting with the group rather than individually (more details in our documentation): + - Added a new `flagSets` prop to the `SplitTreatments` component and `flagSets` option to the `useSplitTreatments` hook options object, to support evaluating flags in given flag set/s. Either `names` or `flagSets` must be provided to the component and hook. If both are provided, `names` will be used. + - Added a new optional Split Filter configuration option. This allows the SDK and Split services to only synchronize the flags in the specified flag sets, avoiding unused or unwanted flags from being synced on the SDK instance, bringing all the benefits from a reduced payload. + - Added `sets` property to the `SplitView` object returned by the `split` and `splits` methods of the SDK manager to expose flag sets on flag views. - Added new `useSplitClient`, `useSplitTreatments` and `useSplitManager` hooks as replacements for the now deprecated `useClient`, `useTreatments` and `useManager` hooks. - These new hooks return the Split context object along with the SDK client, treatments and manager respectively, enabling direct access to status properties like `isReady`, eliminating the need for using the `useContext` hook or the client's `ready` promise. - `useSplitClient` and `useSplitTreatments` accept an options object as parameter, which support the same arguments as their predecessors, with additional boolean options for controlling re-rendering: `updateOnSdkReady`, `updateOnSdkReadyFromCache`, `updateOnSdkTimedout`, and `updateOnSdkUpdate`. - `useSplitTreatments` optimizes feature flag evaluations by using the `useMemo` hook to memoize `getTreatmentsWithConfig` method calls from the SDK. This avoids re-evaluating feature flags when the hook is called with the same options and the feature flag definitions have not changed. - They fixed a bug in the deprecated `useClient` and `useTreatments` hooks, which caused them to not re-render and re-evaluate feature flags when they access a different SDK client than the context and its status updates (i.e., when it emits SDK_READY or other event). - - Added TypeScript types and interfaces to the library index exports, allowing them to be imported, e.g., `import type { ISplitFactoryProps } from '@splitsoftware/splitio-react'` (Related to issue https://github.com/splitio/react-client/issues/162). + - Added TypeScript types and interfaces to the library index exports, allowing them to be imported from the library index, e.g., `import type { ISplitFactoryProps } from '@splitsoftware/splitio-react'` (Related to issue https://github.com/splitio/react-client/issues/162). - Updated type declarations of the library components to not restrict the type of the `children` prop to ReactElement, allowing to pass any valid ReactNode value (Related to issue https://github.com/splitio/react-client/issues/164). - - Updated the `useTreatments` hook to optimize feature flag evaluations. - Updated linter and other dependencies for vulnerability fixes. - Bugfixing - Removed conditional code within hooks to adhere to the rules of hooks and prevent React warnings. Previously, this code checked for the availability of the hooks API (available in React version 16.8.0 or above) and logged an error message. Now, using hooks with React versions below 16.8.0 will throw an error. - Bugfixing - Updated `useClient` and `useTreatments` hooks to re-render and re-evaluate feature flags when they consume a different SDK client than the context and its status updates (i.e., when it emits SDK_READY or other event). diff --git a/package-lock.json b/package-lock.json index a110875..f1c9717 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,15 +1,15 @@ { "name": "@splitsoftware/splitio-react", - "version": "1.9.1-rc.0", + "version": "1.10.0", "lockfileVersion": 2, "requires": true, "packages": { "": { "name": "@splitsoftware/splitio-react", - "version": "1.9.1-rc.0", + "version": "1.10.0", "license": "Apache-2.0", "dependencies": { - "@splitsoftware/splitio": "10.23.0", + "@splitsoftware/splitio": "10.24.0-beta", "memoize-one": "^5.1.1", "shallowequal": "^1.1.0" }, @@ -1547,11 +1547,11 @@ } }, "node_modules/@splitsoftware/splitio": { - "version": "10.23.0", - "resolved": "https://registry.npmjs.org/@splitsoftware/splitio/-/splitio-10.23.0.tgz", - "integrity": "sha512-b9mn2B8U1DfpDETsaWH4T1jhkn8XWwlAVsHwhgIRhCgBs0B9wm4SsXx+OWHZ5bl5uvEwtFFIAtCU58j/irnqpw==", + "version": "10.24.0-beta", + "resolved": "https://registry.npmjs.org/@splitsoftware/splitio/-/splitio-10.24.0-beta.tgz", + "integrity": "sha512-SpYsWoZKLNXtQjQ5xJLJ2BaLZFZBSH3vRJXuYgf1BpsSv6n0s3Lc1NJ4gDI0zRCvGjWEfLOz6VrdBM0klRao8w==", "dependencies": { - "@splitsoftware/splitio-commons": "1.9.0", + "@splitsoftware/splitio-commons": "1.10.1-rc.3", "@types/google.analytics": "0.0.40", "@types/ioredis": "^4.28.0", "bloom-filters": "^3.0.0", @@ -1569,9 +1569,9 @@ } }, "node_modules/@splitsoftware/splitio-commons": { - "version": "1.9.0", - "resolved": "https://registry.npmjs.org/@splitsoftware/splitio-commons/-/splitio-commons-1.9.0.tgz", - "integrity": "sha512-2QoWvGOk/LB+q2TglqGD0w/hcUKG4DZwBSt5NtmT1ODGiLyCf2wbcfG/eBR9QlUnLisJ62dj6vOQsVUB2kiHOw==", + "version": "1.10.1-rc.3", + "resolved": "https://registry.npmjs.org/@splitsoftware/splitio-commons/-/splitio-commons-1.10.1-rc.3.tgz", + "integrity": "sha512-eqJxAMtqFK7fXFKL8gMGfRsMBdxrYI9tIGUHHpY1NcyeKkn4OWqAOZMhX6z2qLdBArzHi34Li0Lb72o+Bh1Tqg==", "dependencies": { "tslib": "^2.3.1" }, @@ -12089,11 +12089,11 @@ } }, "@splitsoftware/splitio": { - "version": "10.23.0", - "resolved": "https://registry.npmjs.org/@splitsoftware/splitio/-/splitio-10.23.0.tgz", - "integrity": "sha512-b9mn2B8U1DfpDETsaWH4T1jhkn8XWwlAVsHwhgIRhCgBs0B9wm4SsXx+OWHZ5bl5uvEwtFFIAtCU58j/irnqpw==", + "version": "10.24.0-beta", + "resolved": "https://registry.npmjs.org/@splitsoftware/splitio/-/splitio-10.24.0-beta.tgz", + "integrity": "sha512-SpYsWoZKLNXtQjQ5xJLJ2BaLZFZBSH3vRJXuYgf1BpsSv6n0s3Lc1NJ4gDI0zRCvGjWEfLOz6VrdBM0klRao8w==", "requires": { - "@splitsoftware/splitio-commons": "1.9.0", + "@splitsoftware/splitio-commons": "1.10.1-rc.3", "@types/google.analytics": "0.0.40", "@types/ioredis": "^4.28.0", "bloom-filters": "^3.0.0", @@ -12105,9 +12105,9 @@ } }, "@splitsoftware/splitio-commons": { - "version": "1.9.0", - "resolved": "https://registry.npmjs.org/@splitsoftware/splitio-commons/-/splitio-commons-1.9.0.tgz", - "integrity": "sha512-2QoWvGOk/LB+q2TglqGD0w/hcUKG4DZwBSt5NtmT1ODGiLyCf2wbcfG/eBR9QlUnLisJ62dj6vOQsVUB2kiHOw==", + "version": "1.10.1-rc.3", + "resolved": "https://registry.npmjs.org/@splitsoftware/splitio-commons/-/splitio-commons-1.10.1-rc.3.tgz", + "integrity": "sha512-eqJxAMtqFK7fXFKL8gMGfRsMBdxrYI9tIGUHHpY1NcyeKkn4OWqAOZMhX6z2qLdBArzHi34Li0Lb72o+Bh1Tqg==", "requires": { "tslib": "^2.3.1" } diff --git a/package.json b/package.json index 64d5d20..4a497b6 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "@splitsoftware/splitio-react", - "version": "1.9.1-rc.0", + "version": "1.10.0", "description": "A React library to easily integrate and use Split JS SDK", "main": "lib/index.js", "module": "es/index.js", @@ -62,7 +62,7 @@ }, "homepage": "https://github.com/splitio/react-client#readme", "dependencies": { - "@splitsoftware/splitio": "10.23.0", + "@splitsoftware/splitio": "10.24.0-beta", "memoize-one": "^5.1.1", "shallowequal": "^1.1.0" }, diff --git a/src/SplitTreatments.tsx b/src/SplitTreatments.tsx index 563b2cf..995f108 100644 --- a/src/SplitTreatments.tsx +++ b/src/SplitTreatments.tsx @@ -1,12 +1,13 @@ import React from 'react'; import { SplitContext } from './SplitContext'; import { ISplitTreatmentsProps, ISplitContextValues } from './types'; -import { getControlTreatmentsWithConfig, WARN_ST_NO_CLIENT } from './constants'; +import { WARN_ST_NO_CLIENT } from './constants'; import { memoizeGetTreatmentsWithConfig } from './utils'; /** - * SplitTreatments accepts a list of feature flag names and optional attributes. It access the client at SplitContext to - * call 'client.getTreatmentsWithConfig()' method, and passes the returned treatments to a child as a function. + * SplitTreatments accepts a list of feature flag names and optional attributes. It accesses the client at SplitContext to + * call the 'client.getTreatmentsWithConfig()' method if the `names` prop is provided, or the 'client.getTreatmentsWithConfigByFlagSets()' method + * if the `flagSets` prop is provided. It then passes the resulting treatments to a child component as a function. * * @see {@link https://help.split.io/hc/en-us/articles/360020448791-JavaScript-SDK#get-treatments-with-configurations} */ @@ -18,22 +19,14 @@ export class SplitTreatments extends React.Component { private evaluateFeatureFlags = memoizeGetTreatmentsWithConfig(); render() { - const { names, children, attributes } = this.props; + const { names, flagSets, children, attributes } = this.props; return ( {(splitContext: ISplitContextValues) => { - const { client, isReady, isReadyFromCache, isDestroyed, lastUpdate } = splitContext; - let treatments; - const isOperational = !isDestroyed && (isReady || isReadyFromCache); - if (client && isOperational) { - // Cloning `client.getAttributes` result for memoization, because it returns the same reference unless `client.clearAttributes` is called. - // Caveat: same issue happens with `names` and `attributes` props if the user follows the bad practice of mutating the object instead of providing a new one. - treatments = this.evaluateFeatureFlags(client, lastUpdate, names, attributes, { ...client.getAttributes() }); - } else { - treatments = getControlTreatmentsWithConfig(names); - if (!client) { this.logWarning = true; } - } + const { client, lastUpdate } = splitContext; + const treatments = this.evaluateFeatureFlags(client, lastUpdate, names, attributes, client ? { ...client.getAttributes() } : {}, flagSets); + if (!client) { this.logWarning = true; } // SplitTreatments only accepts a function as a child, not a React Element (JSX) return children({ ...splitContext, treatments, diff --git a/src/__tests__/SplitTreatments.test.tsx b/src/__tests__/SplitTreatments.test.tsx index 29e641c..13d8990 100644 --- a/src/__tests__/SplitTreatments.test.tsx +++ b/src/__tests__/SplitTreatments.test.tsx @@ -8,31 +8,27 @@ jest.mock('@splitsoftware/splitio/client', () => { }); import { SplitFactory as SplitSdk } from '@splitsoftware/splitio/client'; import { sdkBrowser } from './testUtils/sdkConfigs'; -const logSpy = jest.spyOn(console, 'log'); +import { getStatus } from '../utils'; +import { newSplitFactoryLocalhostInstance } from './testUtils/utils'; +import { CONTROL_WITH_CONFIG, WARN_ST_NO_CLIENT } from '../constants'; /** Test target */ import { ISplitTreatmentsChildProps, ISplitTreatmentsProps, ISplitClientProps } from '../types'; import { SplitTreatments } from '../SplitTreatments'; import { SplitClient } from '../SplitClient'; import { SplitFactory } from '../SplitFactory'; -jest.mock('../constants', () => { - const actual = jest.requireActual('../constants'); - return { - ...actual, - getControlTreatmentsWithConfig: jest.fn(actual.getControlTreatmentsWithConfig), - }; -}); -import { getControlTreatmentsWithConfig, WARN_ST_NO_CLIENT } from '../constants'; -import { getStatus } from '../utils'; -import { newSplitFactoryLocalhostInstance } from './testUtils/utils'; import { useSplitTreatments } from '../useSplitTreatments'; +const logSpy = jest.spyOn(console, 'log'); + describe('SplitTreatments', () => { + const featureFlagNames = ['split1', 'split2']; + const flagSets = ['set1', 'set2']; + afterEach(() => { logSpy.mockClear() }); - it('passes as treatments prop the value returned by the function "getControlTreatmentsWithConfig" if the SDK is not ready.', (done) => { - const featureFlagNames = ['split1', 'split2']; + it('passes control treatments (empty object if flagSets is provided) if the SDK is not ready.', () => { render( {({ factory }) => { @@ -41,9 +37,16 @@ describe('SplitTreatments', () => { {({ treatments }: ISplitTreatmentsChildProps) => { const clientMock: any = factory?.client('user1'); - expect(clientMock.getTreatmentsWithConfig.mock.calls.length).toBe(0); - expect(treatments).toEqual(getControlTreatmentsWithConfig(featureFlagNames)); - done(); + expect(clientMock.getTreatmentsWithConfig).not.toBeCalled(); + expect(treatments).toEqual({ split1: CONTROL_WITH_CONFIG, split2: CONTROL_WITH_CONFIG }); + return null; + }} + + + {({ treatments }: ISplitTreatmentsChildProps) => { + const clientMock: any = factory?.client('user1'); + expect(clientMock.getTreatmentsWithConfigByFlagSets).not.toBeCalled(); + expect(treatments).toEqual({}); return null; }} @@ -54,8 +57,7 @@ describe('SplitTreatments', () => { ); }); - it('passes as treatments prop the value returned by the method "client.getTreatmentsWithConfig" if the SDK is ready.', (done) => { - const featureFlagNames = ['split1', 'split2']; + it('passes as treatments prop the value returned by the method "client.getTreatmentsWithConfig(ByFlagSets)" if the SDK is ready.', () => { const outerFactory = SplitSdk(sdkBrowser); (outerFactory as any).client().__emitter__.emit(Event.SDK_READY); @@ -65,37 +67,44 @@ describe('SplitTreatments', () => { expect(getStatus(outerFactory.client()).isReady).toBe(isReady); expect(isReady).toBe(true); return ( - - {({ treatments, isReady: isReady2, isReadyFromCache, hasTimedout, isTimedout, isDestroyed, lastUpdate }: ISplitTreatmentsChildProps) => { - const clientMock: any = factory?.client(); - expect(clientMock.getTreatmentsWithConfig.mock.calls.length).toBe(1); - expect(treatments).toBe(clientMock.getTreatmentsWithConfig.mock.results[0].value); - expect(featureFlagNames).toBe(clientMock.getTreatmentsWithConfig.mock.calls[0][0]); - expect([isReady2, isReadyFromCache, hasTimedout, isTimedout, isDestroyed, lastUpdate]).toStrictEqual([true, false, false, false, false, 0]); - done(); - return null; - }} - + <> + + {({ treatments, isReady: isReady2, isReadyFromCache, hasTimedout, isTimedout, isDestroyed, lastUpdate }: ISplitTreatmentsChildProps) => { + const clientMock: any = factory?.client(); + expect(clientMock.getTreatmentsWithConfig.mock.calls.length).toBe(1); + expect(treatments).toBe(clientMock.getTreatmentsWithConfig.mock.results[0].value); + expect(featureFlagNames).toBe(clientMock.getTreatmentsWithConfig.mock.calls[0][0]); + expect([isReady2, isReadyFromCache, hasTimedout, isTimedout, isDestroyed, lastUpdate]).toStrictEqual([true, false, false, false, false, 0]); + return null; + }} + + + {({ treatments }: ISplitTreatmentsChildProps) => { + const clientMock: any = factory?.client(); + expect(clientMock.getTreatmentsWithConfigByFlagSets.mock.calls.length).toBe(1); + expect(treatments).toBe(clientMock.getTreatmentsWithConfigByFlagSets.mock.results[0].value); + expect(flagSets).toBe(clientMock.getTreatmentsWithConfigByFlagSets.mock.calls[0][0]); + return null; + }} + + ); }} ); }); - it('logs error and passes control treatments ("getControlTreatmentsWithConfig") if rendered outside an SplitProvider component.', () => { - const featureFlagNames = ['split1', 'split2']; - let passedTreatments; + it('logs error and passes control treatments if rendered outside an SplitProvider component.', () => { render( {({ treatments }: ISplitTreatmentsChildProps) => { - passedTreatments = treatments; + expect(treatments).toEqual({ split1: CONTROL_WITH_CONFIG, split2: CONTROL_WITH_CONFIG }); return null; }} ); + expect(logSpy).toBeCalledWith(WARN_ST_NO_CLIENT); - expect(getControlTreatmentsWithConfig).toBeCalledWith(featureFlagNames); - expect(getControlTreatmentsWithConfig).toHaveReturnedWith(passedTreatments); }); /** @@ -103,8 +112,6 @@ describe('SplitTreatments', () => { * is not ready doesn't emit errors, and logs meaningful messages instead. */ it('Input validation: invalid "names" and "attributes" props in SplitTreatments.', (done) => { - const featureFlagNames = ['split1', 'split2']; - render( {() => { @@ -142,24 +149,40 @@ describe('SplitTreatments', () => { done(); }); + + test('ignores flagSets and logs a warning if both names and flagSets params are provided.', () => { + render( + // @ts-expect-error flagSets and names are mutually exclusive + + {({ treatments }) => { + expect(treatments).toEqual({ split1: CONTROL_WITH_CONFIG, split2: CONTROL_WITH_CONFIG }); + return null; + }} + + ); + + expect(logSpy).toBeCalledWith('[WARN] Both names and flagSets properties were provided. flagSets will be ignored.'); + }); }); let renderTimes = 0; /** - * Tests for asserting that client.getTreatmentsWithConfig is not called unnecessarily when using SplitTreatments and useSplitTreatments. + * Tests for asserting that client.getTreatmentsWithConfig and client.getTreatmentsWithConfigByFlagSets are not called unnecessarily when using SplitTreatments and useSplitTreatments. */ describe.each([ - ({ names, attributes }) => ( - + ({ names, flagSets, attributes }: { names?: string[], flagSets?: string[], attributes?: SplitIO.Attributes }) => ( + // @ts-expect-error names and flagSets are mutually exclusive + {() => { renderTimes++; return null; }} ), - ({ names, attributes }) => { - useSplitTreatments({ names, attributes }); + ({ names, flagSets, attributes }: { names?: string[], flagSets?: string[], attributes?: SplitIO.Attributes }) => { + // @ts-expect-error names and flagSets are mutually exclusive + useSplitTreatments({ names, flagSets, attributes }); renderTimes++; return null; } @@ -167,8 +190,9 @@ describe.each([ let outerFactory = SplitSdk(sdkBrowser); (outerFactory as any).client().__emitter__.emit(Event.SDK_READY); - function Component({ names, attributes, splitKey, clientAttributes }: { - names: ISplitTreatmentsProps['names'] + function Component({ names, flagSets, attributes, splitKey, clientAttributes }: { + names?: ISplitTreatmentsProps['names'] + flagSets?: ISplitTreatmentsProps['flagSets'] attributes: ISplitTreatmentsProps['attributes'] splitKey: ISplitClientProps['splitKey'] clientAttributes?: ISplitClientProps['attributes'] @@ -176,13 +200,14 @@ describe.each([ return ( - + ); } const names = ['split1', 'split2']; + const flagSets = ['set1', 'set2']; const attributes = { att1: 'att1' }; const splitKey = sdkBrowser.core.key; @@ -191,25 +216,27 @@ describe.each([ beforeEach(() => { renderTimes = 0; (outerFactory.client().getTreatmentsWithConfig as jest.Mock).mockClear(); - wrapper = render(); + wrapper = render(); }) afterEach(() => { wrapper.unmount(); // unmount to remove event listener from factory }) - it('rerenders but does not re-evaluate feature flags if client, lastUpdate, names and attributes are the same object.', () => { - wrapper.rerender(); + it('rerenders but does not re-evaluate feature flags if client, lastUpdate, names, flagSets and attributes are the same object.', () => { + wrapper.rerender(); expect(renderTimes).toBe(2); expect(outerFactory.client().getTreatmentsWithConfig).toBeCalledTimes(1); + expect(outerFactory.client().getTreatmentsWithConfigByFlagSets).toBeCalledTimes(0); }); - it('rerenders but does not re-evaluate feature flags if client, lastUpdate, names and attributes are equals (shallow comparison).', () => { - wrapper.rerender(); + it('rerenders but does not re-evaluate feature flags if client, lastUpdate, names, flagSets and attributes are equals (shallow comparison).', () => { + wrapper.rerender(); expect(renderTimes).toBe(2); expect(outerFactory.client().getTreatmentsWithConfig).toBeCalledTimes(1); + expect(outerFactory.client().getTreatmentsWithConfigByFlagSets).toBeCalledTimes(0); }); it('rerenders and re-evaluates feature flags if names are not equals (shallow array comparison).', () => { @@ -217,6 +244,19 @@ describe.each([ expect(renderTimes).toBe(2); expect(outerFactory.client().getTreatmentsWithConfig).toBeCalledTimes(2); + expect(outerFactory.client().getTreatmentsWithConfigByFlagSets).toBeCalledTimes(0); + }); + + it('rerenders and re-evaluates feature flags if flag sets are not equals (shallow array comparison).', () => { + wrapper.rerender(); + wrapper.rerender(); + expect(outerFactory.client().getTreatmentsWithConfigByFlagSets).toBeCalledTimes(1); + + wrapper.rerender(); + + expect(renderTimes).toBe(4); + expect(outerFactory.client().getTreatmentsWithConfig).toBeCalledTimes(1); + expect(outerFactory.client().getTreatmentsWithConfigByFlagSets).toBeCalledTimes(2); }); it('rerenders and re-evaluates feature flags if attributes are not equals (shallow object comparison).', () => { @@ -265,8 +305,6 @@ describe.each([ it('rerenders and re-evaluate feature flags when Split context changes (in both SplitFactory and SplitClient components).', async () => { // changes in SplitContext implies that either the factory, the client (user key), or its status changed, what might imply a change in treatments const outerFactory = SplitSdk(sdkBrowser); - const names = ['split1', 'split2']; - const attributes = { att1: 'att1' }; let renderTimesComp1 = 0; let renderTimesComp2 = 0; diff --git a/src/__tests__/testUtils/mockSplitSdk.ts b/src/__tests__/testUtils/mockSplitSdk.ts index 846e104..22d40ae 100644 --- a/src/__tests__/testUtils/mockSplitSdk.ts +++ b/src/__tests__/testUtils/mockSplitSdk.ts @@ -53,6 +53,12 @@ function mockClient(_key: SplitIO.SplitKey, _trafficType?: string) { return result; }, {}); }); + const getTreatmentsWithConfigByFlagSets: jest.Mock = jest.fn((flagSets: string[]) => { + return flagSets.reduce((result: SplitIO.TreatmentsWithConfig, flagSet: string) => { + result[flagSet + '_feature_flag'] = { treatment: 'on', config: null }; + return result; + }, {}); + }); const setAttributes: jest.Mock = jest.fn((attributes) => { attributesCache = Object.assign(attributesCache, attributes); return true; @@ -87,6 +93,7 @@ function mockClient(_key: SplitIO.SplitKey, _trafficType?: string) { return Object.assign(Object.create(__emitter__), { getTreatmentsWithConfig, + getTreatmentsWithConfigByFlagSets, track, ready, destroy, diff --git a/src/__tests__/useSplitTreatments.test.tsx b/src/__tests__/useSplitTreatments.test.tsx index 168bf0e..cbdc62d 100644 --- a/src/__tests__/useSplitTreatments.test.tsx +++ b/src/__tests__/useSplitTreatments.test.tsx @@ -8,15 +8,7 @@ jest.mock('@splitsoftware/splitio/client', () => { }); import { SplitFactory as SplitSdk } from '@splitsoftware/splitio/client'; import { sdkBrowser } from './testUtils/sdkConfigs'; -jest.mock('../constants', () => { - const actual = jest.requireActual('../constants'); - return { - ...actual, - getControlTreatmentsWithConfig: jest.fn(actual.getControlTreatmentsWithConfig), - }; -}); -import { CONTROL_WITH_CONFIG, getControlTreatmentsWithConfig } from '../constants'; -const logSpy = jest.spyOn(console, 'log'); +import { CONTROL_WITH_CONFIG } from '../constants'; /** Test target */ import { SplitFactory } from '../SplitFactory'; @@ -26,20 +18,28 @@ import { SplitTreatments } from '../SplitTreatments'; import { SplitContext } from '../SplitContext'; import { ISplitTreatmentsChildProps } from '../types'; +const logSpy = jest.spyOn(console, 'log'); + describe('useSplitTreatments', () => { const featureFlagNames = ['split1']; + const flagSets = ['set1']; const attributes = { att1: 'att1' }; - test('returns the treatments evaluated by the client at Split context updated by SplitFactory, or control if the client is not operational.', () => { + test('returns the treatments evaluated by the client at Split context, or control if the client is not operational.', () => { const outerFactory = SplitSdk(sdkBrowser); const client: any = outerFactory.client(); let treatments: SplitIO.TreatmentsWithConfig; + let treatmentsByFlagSets: SplitIO.TreatmentsWithConfig; render( {React.createElement(() => { treatments = useSplitTreatments({ names: featureFlagNames, attributes }).treatments; + treatmentsByFlagSets = useSplitTreatments({ flagSets, attributes }).treatments; + + // @ts-expect-error Options object must provide either names or flagSets + expect(useSplitTreatments({}).treatments).toEqual({}); return null; })} @@ -49,14 +49,21 @@ describe('useSplitTreatments', () => { expect(client.getTreatmentsWithConfig).not.toBeCalled(); expect(treatments!).toEqual({ split1: CONTROL_WITH_CONFIG }); + // returns empty treatments object if not operational, without calling `getTreatmentsWithConfigByFlagSets` method + expect(client.getTreatmentsWithConfigByFlagSets).not.toBeCalled(); + expect(treatmentsByFlagSets!).toEqual({}); + // once operational (SDK_READY), it evaluates feature flags act(() => client.__emitter__.emit(Event.SDK_READY)); expect(client.getTreatmentsWithConfig).toBeCalledWith(featureFlagNames, attributes); expect(client.getTreatmentsWithConfig).toHaveReturnedWith(treatments); + + expect(client.getTreatmentsWithConfigByFlagSets).toBeCalledWith(flagSets, attributes); + expect(client.getTreatmentsWithConfigByFlagSets).toHaveReturnedWith(treatmentsByFlagSets); }); - test('returns the Treatments from the client at Split context updated by SplitClient, or control if the client is not operational.', async () => { + test('returns the treatments from the client at Split context updated by SplitClient, or control if the client is not operational.', async () => { const outerFactory = SplitSdk(sdkBrowser); const client: any = outerFactory.client('user2'); let treatments: SplitIO.TreatmentsWithConfig; @@ -83,7 +90,7 @@ describe('useSplitTreatments', () => { expect(client.getTreatmentsWithConfig).toHaveReturnedWith(treatments); }); - test('returns the Treatments from a new client given a splitKey, and re-evaluates on SDK events.', () => { + test('returns the treatments from a new client given a splitKey, and re-evaluates on SDK events.', () => { const outerFactory = SplitSdk(sdkBrowser); const client: any = outerFactory.client('user2'); let renderTimes = 0; @@ -122,17 +129,17 @@ describe('useSplitTreatments', () => { }); // THE FOLLOWING TEST WILL PROBABLE BE CHANGED BY 'return a null value or throw an error if it is not inside an SplitProvider' - test('returns Control Treatments if invoked outside Split context.', () => { - let treatments; - + test('returns control treatments (empty object if flagSets is provided) if invoked outside Split context.', () => { render( React.createElement(() => { - treatments = useSplitTreatments({ names: featureFlagNames, attributes }).treatments; + const treatments = useSplitTreatments({ names: featureFlagNames, attributes }).treatments; + expect(treatments).toEqual({ split1: CONTROL_WITH_CONFIG }); + + const treatmentsByFlagSets = useSplitTreatments({ flagSets: featureFlagNames }).treatments; + expect(treatmentsByFlagSets).toEqual({}); return null; }) ); - expect(getControlTreatmentsWithConfig).toBeCalledWith(featureFlagNames); - expect(getControlTreatmentsWithConfig).toHaveReturnedWith(treatments); }); /** @@ -239,4 +246,17 @@ describe('useSplitTreatments', () => { expect(user2Client.getTreatmentsWithConfig).toHaveBeenLastCalledWith(['split_test'], undefined); }); + test('ignores flagSets and logs a warning if both names and flagSets params are provided.', () => { + render( + React.createElement(() => { + // @ts-expect-error names and flagSets are mutually exclusive + const treatments = useSplitTreatments({ names: featureFlagNames, flagSets, attributes }).treatments; + expect(treatments).toEqual({ split1: CONTROL_WITH_CONFIG }); + return null; + }) + ); + + expect(logSpy).toHaveBeenLastCalledWith('[WARN] Both names and flagSets properties were provided. flagSets will be ignored.'); + }); + }); diff --git a/src/constants.ts b/src/constants.ts index 978dc58..47020ef 100644 --- a/src/constants.ts +++ b/src/constants.ts @@ -30,12 +30,14 @@ export const getControlTreatmentsWithConfig = (featureFlagNames: unknown): Split }; // Warning and error messages -export const WARN_SF_CONFIG_AND_FACTORY: string = '[WARN] Both a config and factory props were provided to SplitFactory. Config prop will be ignored.'; +export const WARN_SF_CONFIG_AND_FACTORY: string = '[WARN] Both a config and factory props were provided to SplitFactory. Config prop will be ignored.'; export const ERROR_SF_NO_CONFIG_AND_FACTORY: string = '[ERROR] SplitFactory must receive either a Split config or a Split factory as props.'; export const ERROR_SC_NO_FACTORY: string = '[ERROR] SplitClient does not have access to a Split factory. This is because it is not inside the scope of a SplitFactory component or SplitFactory was not properly instantiated.'; -export const WARN_ST_NO_CLIENT: string = '[WARN] SplitTreatments does not have access to a Split client. This is because it is not inside the scope of a SplitFactory component or SplitFactory was not properly instantiated.'; +export const WARN_ST_NO_CLIENT: string = '[WARN] SplitTreatments does not have access to a Split client. This is because it is not inside the scope of a SplitFactory component or SplitFactory was not properly instantiated.'; export const EXCEPTION_NO_REACT_OR_CREATECONTEXT: string = 'React library is not available or its version is not supported. Check that it is properly installed or imported. Split SDK requires version 16.3.0+ of React.'; + +export const WARN_NAMES_AND_FLAGSETS: string = '[WARN] Both names and flagSets properties were provided. flagSets will be ignored.'; diff --git a/src/types.ts b/src/types.ts index 0a9ecdd..81b735e 100644 --- a/src/types.ts +++ b/src/types.ts @@ -170,18 +170,34 @@ export interface ISplitClientProps extends IUseSplitClientOptions { children: ((props: ISplitClientChildProps) => ReactNode) | ReactNode; } -/** - * useSplitTreatments options interface. This is the options object accepted by useSplitTreatments hook, - * used to call 'client.getTreatmentsWithConfig()' and retrieve the result together with the Split context. - */ -export interface IUseSplitTreatmentsOptions extends IUseSplitClientOptions { +export type GetTreatmentsOptions = ({ + + /** + * 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 names + * 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. */ - names: string[] + flagSets: string[]; + names?: undefined; +}) & { + + /** + * An object of type Attributes used to evaluate the feature flags. + */ + attributes?: SplitIO.Attributes; } +/** + * useSplitTreatments options interface. This is the options object accepted by useSplitTreatments hook, + * used to call 'client.getTreatmentsWithConfig()', or 'client.getTreatmentsWithConfigByFlagSets()', and retrieve the result together with the Split context. + */ +export type IUseSplitTreatmentsOptions = GetTreatmentsOptions & IUseSplitClientOptions; + /** * SplitTreatments Child Props interface. These are the props that the child component receives from the 'SplitTreatments' component. */ @@ -200,19 +216,9 @@ export interface ISplitTreatmentsChildProps extends ISplitContextValues { /** * SplitTreatments Props interface. These are the props accepted by SplitTreatments component, - * used to call 'client.getTreatmentsWithConfig()' and pass the result to the child component. + * used to call 'client.getTreatmentsWithConfig()', or 'client.getTreatmentsWithConfigByFlagSets()', and pass the result to the child component. */ -export interface ISplitTreatmentsProps { - - /** - * list of feature flag names - */ - names: string[]; - - /** - * An object of type Attributes used to evaluate the feature flags. - */ - attributes?: SplitIO.Attributes; +export type ISplitTreatmentsProps = GetTreatmentsOptions & { /** * Children of the SplitTreatments component. It must be a functional component (child as a function) you want to show. diff --git a/src/useSplitTreatments.ts b/src/useSplitTreatments.ts index 0d68615..d5dbf6d 100644 --- a/src/useSplitTreatments.ts +++ b/src/useSplitTreatments.ts @@ -1,12 +1,12 @@ import React from 'react'; -import { getControlTreatmentsWithConfig } from './constants'; -import { IClientWithContext, memoizeGetTreatmentsWithConfig } from './utils'; +import { memoizeGetTreatmentsWithConfig } from './utils'; import { ISplitTreatmentsChildProps, IUseSplitTreatmentsOptions } from './types'; import { useSplitClient } from './useSplitClient'; /** * 'useSplitTreatments' is a hook that returns an SplitContext object extended with a `treatments` property object that contains feature flag evaluations. - * It uses the 'useSplitClient' hook to access the client from the Split context, and invokes the 'getTreatmentsWithConfig' method. + * It uses the 'useSplitClient' hook to access the client from the Split context, and invokes the 'client.getTreatmentsWithConfig()' method if the `names` option is provided, + * or the 'client.getTreatmentsWithConfigByFlagSets()' method if the `flagSets` option is provided. * * @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. * @@ -18,15 +18,15 @@ import { useSplitClient } from './useSplitClient'; * @see {@link https://help.split.io/hc/en-us/articles/360020448791-JavaScript-SDK#get-treatments-with-configurations} */ export function useSplitTreatments(options: IUseSplitTreatmentsOptions): ISplitTreatmentsChildProps { - const context = useSplitClient({...options, attributes: undefined }); + const context = useSplitClient({ ...options, attributes: undefined }); const { client, lastUpdate } = context; - const { names, attributes } = options; + const { names, flagSets, attributes } = options; const getTreatmentsWithConfig = React.useMemo(memoizeGetTreatmentsWithConfig, []); - const treatments = client && (client as IClientWithContext).__getStatus().isOperational ? - getTreatmentsWithConfig(client, lastUpdate, names, attributes, { ...client.getAttributes() }) : - getControlTreatmentsWithConfig(names); + // 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); return { ...context, diff --git a/src/utils.ts b/src/utils.ts index 451582c..ee42523 100644 --- a/src/utils.ts +++ b/src/utils.ts @@ -1,7 +1,7 @@ import memoizeOne from 'memoize-one'; import shallowEqual from 'shallowequal'; import { SplitFactory as SplitSdk } from '@splitsoftware/splitio/client'; -import { VERSION } from './constants'; +import { VERSION, WARN_NAMES_AND_FLAGSETS, getControlTreatmentsWithConfig } from './constants'; import { ISplitStatus } from './types'; // Utils used to access singleton instances of Split factories and clients, and to gracefully shutdown all clients together. @@ -176,9 +176,18 @@ function argsAreEqual(newArgs: any[], lastArgs: any[]): boolean { newArgs[1] === lastArgs[1] && // lastUpdate shallowEqual(newArgs[2], lastArgs[2]) && // names shallowEqual(newArgs[3], lastArgs[3]) && // attributes - shallowEqual(newArgs[4], lastArgs[4]); // client attributes + shallowEqual(newArgs[4], lastArgs[4]) && // client attributes + shallowEqual(newArgs[5], lastArgs[5]); // flagSets } -function evaluateFeatureFlags(client: SplitIO.IBrowserClient, lastUpdate: number, names: SplitIO.SplitNames, attributes?: SplitIO.Attributes, _clientAttributes?: SplitIO.Attributes) { - return client.getTreatmentsWithConfig(names, attributes); +function evaluateFeatureFlags(client: SplitIO.IBrowserClient | null, _lastUpdate: number, names?: SplitIO.SplitNames, attributes?: SplitIO.Attributes, _clientAttributes?: SplitIO.Attributes, flagSets?: string[]) { + if (names && flagSets) console.log(WARN_NAMES_AND_FLAGSETS); + + return client && (client as IClientWithContext).__getStatus().isOperational && (names || flagSets) ? + names ? + client.getTreatmentsWithConfig(names, attributes) : + client.getTreatmentsWithConfigByFlagSets(flagSets!, attributes) : + names ? + getControlTreatmentsWithConfig(names) : + {} // empty object when evaluating with flag sets and client is not ready }