diff --git a/CHANGES.txt b/CHANGES.txt index 926feea..0346c5e 100644 --- a/CHANGES.txt +++ b/CHANGES.txt @@ -1,3 +1,8 @@ +1.13.0 (May 24, 2024) + - Added a new `getStatus` helper function to retrieve the status properties of the SDK manager and clients: `isReady`, `isReadyFromCache`, `hasTimedout`, and `isDestroyed`. + - Added new `selectTreatmentAndStatus` and `selectTreatmentWithConfigAndStatus` selectors as alternatives to the `selectTreatmentValue` and `selectTreatmentWithConfig` selectors, respectively. + The new selectors retrieve an object with the treatment and the status properties of the client associated with the provided key, allowing conditional rendering based on the SDK status. + 1.12.0 (May 10, 2024) - Updated @splitsoftware/splitio package to version 10.26.0 that includes minor updates: - Added support for targeting rules based on semantic versions (https://semver.org/). @@ -55,7 +60,7 @@ 1.6.0 (Jul 7, 2022) - Updated @splitsoftware/splitio dependency to version 10.20.0, which includes: - - Added a new config option to control the tasks that listen or poll for updates on feature flags and segments, via the new config sync.enabled . Running online Split will always pull the most recent updates upon initialization, this only affects updates fetching on a running instance. Useful when a consistent session experience is a must or to save resources when updates are not being used. + - Added a new config option to control the tasks that listen or poll for updates on feature flags and segments, via the new config `sync.enabled`. Running online Split will always pull the most recent updates upon initialization, this only affects updates fetching on a running instance. Useful when a consistent session experience is a must or to save resources when updates are not being used. - Updated telemetry logic to track the anonymous config for user consent flag set to declined or unknown. - Updated submitters logic, to avoid duplicating the post of impressions to Split cloud when the SDK is destroyed while its periodic post of impressions is running. - Added `scheduler.telemetryRefreshRate` property to SDK configuration, and deprecated `scheduler.metricsRefreshRate` property. @@ -76,7 +81,7 @@ - Updated localhost mode to emit SDK_READY_FROM_CACHE event in Browser when using localStorage (issue https://github.com/splitio/react-client/issues/34). - Updated streaming logic to use the newest version of our streaming service, including: - Integration with Auth service V2, connecting to the new channels and applying the received connection delay. - - Implemented handling of the new MySegmentsV2 notification types (SegmentRemoval, KeyList, Bounded and Unbounded) + - Implemented handling of the new MySegmentsV2 notification types (SegmentRemoval, KeyList, Bounded and Unbounded). - New control notification for environment scoped streaming reset. - Updated Enzyme and Jest development dependencies to fix vulnerabilities. @@ -104,13 +109,13 @@ - Added an optional callback parameter to `destroySplitSdk` action creator: `onDestroy`, to listen when the SDK has gracefully shut down. 1.1.0 (May 11, 2020) - - Bugfixing - incorrect evaluation of feature flags on browser when using `getTreatments` with a different user key than the default, caused by not waiting the fetch of segments. + - Bugfixing - Incorrect evaluation of feature flags on browser when using `getTreatments` with a different user key than the default, caused by not waiting the fetch of segments (Related to issue https://github.com/splitio/redux-client/issues/9). - Added `destroySplitSdk` action creator to gracefully shutdown the SDK. - Added two new status properties to split's piece of state: `hasTimedout` and `isDestroyed` to better reflect the current state of the associated factory. 1.0.1 (April 6, 2020) - - Updated dependencies to fix vulnerabilities - - Bugfixing - support numbers as user keys + - Updated dependencies to fix vulnerabilities. + - Bugfixing - Support numbers as user keys. 1.0.0 (January 24, 2020) - Initial public release! diff --git a/README.md b/README.md index c0b56a7..9041027 100644 --- a/README.md +++ b/README.md @@ -21,7 +21,7 @@ import React from 'react'; import { createStore, applyMiddleware, combineReducers } from 'redux'; import { Provider } from 'react-redux'; import { splitReducer, initSplitSdk, getTreatments, - selectTreatmentValue, connectSplit } from '@splitsoftware/splitio-redux' + selectTreatmentAndStatus, connectSplit } from '@splitsoftware/splitio-redux' // Init Redux store const store = createStore( @@ -47,12 +47,12 @@ store.dispatch(getTreatments({ splitNames: 'FEATURE_FLAG_NAME' })) // Connect your component to splitio's piece of state const MyComponent = connectSplit()(({ splitio }) => { - // Check SDK readiness using isReady property - if (!splitio.isReady) - return
Loading SDK ...
; - // Select a treatment value - const treatment = selectTreatmentValue(splitio, 'FEATURE_FLAG_NAME') + const { treatment, isReady } = selectTreatmentAndStatus(splitio, 'FEATURE_FLAG_NAME') + + // Check SDK client readiness using isReady property + if (!isReady) return
Loading SDK ...
; + if (treatment === 'on') { // return JSX for 'on' treatment } else if (treatment === 'off') { diff --git a/package-lock.json b/package-lock.json index ffcd7b5..76a4c02 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "@splitsoftware/splitio-redux", - "version": "1.12.0", + "version": "1.13.0", "lockfileVersion": 2, "requires": true, "packages": { "": { "name": "@splitsoftware/splitio-redux", - "version": "1.12.0", + "version": "1.13.0", "license": "Apache-2.0", "dependencies": { "@splitsoftware/splitio": "10.26.0", diff --git a/package.json b/package.json index 83030b7..9da3da2 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "@splitsoftware/splitio-redux", - "version": "1.12.0", + "version": "1.13.0", "description": "A library to easily use Split JS SDK with Redux and React Redux", "main": "lib/index.js", "module": "es/index.js", diff --git a/src/__tests__/index.test.ts b/src/__tests__/index.test.ts index a6ff13b..9641701 100644 --- a/src/__tests__/index.test.ts +++ b/src/__tests__/index.test.ts @@ -10,6 +10,8 @@ import { getSplits as exportedGetSplits, selectTreatmentValue as exportedSelectTreatmentValue, selectTreatmentWithConfig as exportedSelectTreatmentWithConfig, + selectTreatmentAndStatus as exportedSelectTreatmentAndStatus, + selectTreatmentWithConfigAndStatus as exportedSelectTreatmentWithConfigAndStatus, connectSplit as exportedConnectSplit, connectToggler as exportedConnectToggler, mapTreatmentToProps as exportedMapTreatmentToProps, @@ -21,7 +23,7 @@ import { import { splitReducer } from '../reducer'; import { initSplitSdk, getTreatments, destroySplitSdk, splitSdk } from '../asyncActions'; import { track, getSplitNames, getSplit, getSplits } from '../helpers'; -import { selectTreatmentValue, selectTreatmentWithConfig } from '../selectors'; +import { selectTreatmentValue, selectTreatmentWithConfig, selectTreatmentAndStatus, selectTreatmentWithConfigAndStatus } from '../selectors'; import { connectSplit } from '../react-redux/connectSplit'; import { connectToggler, mapTreatmentToProps, mapIsFeatureOnToProps } from '../react-redux/connectToggler'; @@ -38,6 +40,8 @@ it('index should export modules', () => { expect(exportedGetSplits).toBe(getSplits); expect(exportedSelectTreatmentValue).toBe(selectTreatmentValue); expect(exportedSelectTreatmentWithConfig).toBe(selectTreatmentWithConfig); + expect(exportedSelectTreatmentAndStatus).toBe(selectTreatmentAndStatus); + expect(exportedSelectTreatmentWithConfigAndStatus).toBe(selectTreatmentWithConfigAndStatus); expect(exportedConnectSplit).toBe(connectSplit); expect(exportedConnectToggler).toBe(connectToggler); expect(exportedMapTreatmentToProps).toBe(mapTreatmentToProps); diff --git a/src/__tests__/selectors.test.ts b/src/__tests__/selectors.test.ts index f2a1fbf..0583e9b 100644 --- a/src/__tests__/selectors.test.ts +++ b/src/__tests__/selectors.test.ts @@ -35,7 +35,7 @@ describe('selectTreatmentValue', () => { expect(selectTreatmentValue(STATE_READY.splitio, SPLIT_INVALID, USER_1, 'some_value')).toBe('some_value'); }); - it('returns "control" and log error if the given splitState is invalid', () => { + it('returns "control" and logs error if the given splitState is invalid', () => { const errorSpy = jest.spyOn(console, 'error'); expect(selectTreatmentValue((STATE_READY as unknown as ISplitState), SPLIT_1, USER_INVALID)).toBe(CONTROL); expect(errorSpy).toBeCalledWith(ERROR_SELECTOR_NO_SPLITSTATE); @@ -58,14 +58,14 @@ describe('selectTreatmentWithConfig', () => { expect(selectTreatmentWithConfig(STATE_READY.splitio, SPLIT_INVALID, USER_1)).toBe(CONTROL_WITH_CONFIG); }); - it('returns the passed default treatment insteaad of "control" if the given feature flag name or key are invalid', () => { + it('returns the passed default treatment instead of "control" if the given feature flag name or key are invalid', () => { const DEFAULT_TREATMENT = { treatment: 'some_value', config: 'some_config' }; expect(selectTreatmentWithConfig(STATE_READY.splitio, SPLIT_1, USER_INVALID, DEFAULT_TREATMENT)).toBe(DEFAULT_TREATMENT); expect(selectTreatmentWithConfig(STATE_READY.splitio, SPLIT_INVALID, USER_1, DEFAULT_TREATMENT)).toBe(DEFAULT_TREATMENT); }); - it('returns "control" and log error if the given splitState is invalid', () => { + it('returns "control" and logs error if the given splitState is invalid', () => { const errorSpy = jest.spyOn(console, 'error'); expect(selectTreatmentWithConfig((STATE_READY as unknown as ISplitState), SPLIT_1, USER_INVALID)).toBe(CONTROL_WITH_CONFIG); expect(errorSpy).toBeCalledWith(ERROR_SELECTOR_NO_SPLITSTATE); diff --git a/src/__tests__/selectorsWithStatus.test.ts b/src/__tests__/selectorsWithStatus.test.ts new file mode 100644 index 0000000..bf3a7d6 --- /dev/null +++ b/src/__tests__/selectorsWithStatus.test.ts @@ -0,0 +1,100 @@ +/** Mocks */ +import { SPLIT_1, SPLIT_2, STATE_READY, USER_1 } from './utils/storeState'; +import { mockSdk, Event } from './utils/mockBrowserSplitSdk'; +jest.mock('@splitsoftware/splitio', () => { + return { SplitFactory: mockSdk() }; +}); + +import mockStore from './utils/mockStore'; +import { STATE_INITIAL, STATUS_INITIAL } from './utils/storeState'; +import { sdkBrowserConfig } from './utils/sdkConfigs'; +import { initSplitSdk, getTreatments, splitSdk } from '../asyncActions'; + +/** Constants */ +import { ON, CONTROL, CONTROL_WITH_CONFIG, ERROR_SELECTOR_NO_SPLITSTATE } from '../constants'; + +/** Test targets */ +import { + selectTreatmentAndStatus, + selectTreatmentWithConfigAndStatus +} from '../selectors'; + +describe('selectTreatmentAndStatus & selectTreatmentWithConfigAndStatus', () => { + + const errorSpy = jest.spyOn(console, 'error'); + + beforeEach(() => { + errorSpy.mockClear(); + }); + + it('if Split state is invalid or SDK was not initialized, returns default treatment and initial status', () => { + const DEFAULT_TREATMENT = { treatment: 'some_value', config: 'some_config' }; + + expect(selectTreatmentWithConfigAndStatus({} as any, SPLIT_1, USER_1, DEFAULT_TREATMENT)).toEqual({ + treatment: DEFAULT_TREATMENT, + ...STATUS_INITIAL, + }); + expect(errorSpy).toHaveBeenCalledWith(ERROR_SELECTOR_NO_SPLITSTATE); + errorSpy.mockClear(); + + expect(selectTreatmentAndStatus(STATE_INITIAL.splitio, SPLIT_1, USER_1, 'default_value')).toEqual({ + treatment: 'default_value', + ...STATUS_INITIAL, + }); + expect(errorSpy).not.toHaveBeenCalled(); + }); + + it('if getTreatments action was not dispatched for the provided feature flag and key, returns default treatment and client status', () => { + const store = mockStore(STATE_INITIAL); + store.dispatch(initSplitSdk({ config: sdkBrowserConfig })); + (splitSdk.factory as any).client().__emitter__.emit(Event.SDK_READY); + + expect(selectTreatmentAndStatus(STATE_INITIAL.splitio, SPLIT_1)).toEqual({ + treatment: CONTROL, + // status of main client: + ...STATUS_INITIAL, isReady: true, isOperational: true, + }); + + expect(selectTreatmentAndStatus(STATE_INITIAL.splitio, SPLIT_1, USER_1, 'some_value')).toEqual({ + treatment: 'some_value', + // USER_1 client has not been initialized yet: + ...STATUS_INITIAL, + }); + + store.dispatch(getTreatments({ key: USER_1, splitNames: [SPLIT_2] })); + (splitSdk.factory as any).client(USER_1).__emitter__.emit(Event.SDK_READY_FROM_CACHE); + + expect(selectTreatmentWithConfigAndStatus(STATE_INITIAL.splitio, SPLIT_2, USER_1)).toEqual({ + treatment: CONTROL_WITH_CONFIG, + // status of shared client: + ...STATUS_INITIAL, isReadyFromCache: true, isOperational: true, + }); + + expect(errorSpy).not.toHaveBeenCalled(); + }); + + it('happy path: returns the treatment value and status of the client', () => { + // The following actions result in STATE_READY state: + const store = mockStore(); + store.dispatch(initSplitSdk({ config: sdkBrowserConfig })); + (splitSdk.factory as any).client().__emitter__.emit(Event.SDK_READY); + (splitSdk.factory as any).client(USER_1).__emitter__.emit(Event.SDK_READY_FROM_CACHE); + store.dispatch(getTreatments({ splitNames: [SPLIT_1] })); + store.dispatch(getTreatments({ key: USER_1, splitNames: [SPLIT_2] })); + + expect(selectTreatmentAndStatus(STATE_READY.splitio, SPLIT_1)).toEqual({ + treatment: ON, + ...STATUS_INITIAL, + isReady: true, isOperational: true, + }); + + expect(selectTreatmentWithConfigAndStatus(STATE_READY.splitio, SPLIT_2, USER_1)).toEqual({ + treatment: STATE_READY.splitio.treatments[SPLIT_2][USER_1], + ...STATUS_INITIAL, + isReadyFromCache: true, isOperational: true, + }); + + expect(errorSpy).not.toHaveBeenCalled(); + }); + +}); diff --git a/src/__tests__/utils/mockStore.ts b/src/__tests__/utils/mockStore.ts index 48c720c..f1b0906 100644 --- a/src/__tests__/utils/mockStore.ts +++ b/src/__tests__/utils/mockStore.ts @@ -4,6 +4,7 @@ import configureMockStore from 'redux-mock-store'; const middlewares: any[] = [thunk]; /** - * Utils to not call requires files every time that we need mock the store + * redux-mock-store is designed to test the action-related logic, not the reducer-related one. In other words, it does not update the Redux store. + * Use storeState.ts for mocks of the Redux store state. */ export default configureMockStore(middlewares); diff --git a/src/helpers.ts b/src/helpers.ts index 740afce..1b35fcf 100644 --- a/src/helpers.ts +++ b/src/helpers.ts @@ -109,7 +109,7 @@ export function getStatus(key?: SplitIO.SplitKey): IStatus { if (client) return __getStatus(client); } - // Default status if SDK is not initialized or client is not found + // Default status if SDK is not initialized or client is not found. No warning logs for now, in case the helper is used before actions are dispatched return { isReady: false, isReadyFromCache: false, diff --git a/src/index.ts b/src/index.ts index 9aeeb44..c99eda8 100644 --- a/src/index.ts +++ b/src/index.ts @@ -2,7 +2,7 @@ export { splitReducer } from './reducer'; export { initSplitSdk, getTreatments, destroySplitSdk, splitSdk } from './asyncActions'; export { track, getSplitNames, getSplit, getSplits, getStatus } from './helpers'; -export { selectTreatmentValue, selectTreatmentWithConfig } from './selectors'; +export { selectTreatmentValue, selectTreatmentWithConfig, selectTreatmentAndStatus, selectTreatmentWithConfigAndStatus } from './selectors'; // For React-redux export { connectSplit } from './react-redux/connectSplit'; diff --git a/src/selectors.ts b/src/selectors.ts index 25ca3d0..65750d9 100644 --- a/src/selectors.ts +++ b/src/selectors.ts @@ -1,13 +1,16 @@ -import { ISplitState } from './types'; +import { ISplitState, IStatus } from './types'; import { CONTROL, CONTROL_WITH_CONFIG, DEFAULT_SPLIT_STATE_SLICE, ERROR_SELECTOR_NO_SPLITSTATE } from './constants'; import { matching } from './utils'; +import { getStatus } from './helpers'; export const getStateSlice = (sliceName: string) => (state: any) => state[sliceName]; export const defaultGetSplitState = getStateSlice(DEFAULT_SPLIT_STATE_SLICE); /** - * Selector function to extract a treatment evaluation from the Split state. It returns the treatment string value. + * This function extracts a treatment evaluation from the Split state. It returns the treatment string value. + * If a treatment is not found, it returns the default value, which is `'control'` if not specified. + * A treatment is not found if an invalid Split state is passed or if a `getTreatments` action has not been dispatched for the provided feature flag name and key. * * @param {ISplitState} splitState * @param {string} featureFlagName @@ -19,12 +22,14 @@ export function selectTreatmentValue(splitState: ISplitState, featureFlagName: s } /** - * Selector function to extract a treatment evaluation from the Split state. It returns a treatment object containing its value and configuration. + * This function extracts a treatment evaluation from the Split state. It returns a treatment object containing its value and configuration. + * If a treatment is not found, it returns the default value, which is `{ treatment: 'control', configuration: null }` if not specified. + * A treatment is not found if an invalid Split state is passed or if a `getTreatments` action has not been dispatched for the provided feature flag name and key. * * @param {ISplitState} splitState * @param {string} featureFlagName * @param {SplitIO.SplitKey} key - * @param {TreatmentWithConfig} defaultValue + * @param {SplitIO.TreatmentWithConfig} defaultValue */ export function selectTreatmentWithConfig(splitState: ISplitState, featureFlagName: string, key?: SplitIO.SplitKey, defaultValue: SplitIO.TreatmentWithConfig = CONTROL_WITH_CONFIG): SplitIO.TreatmentWithConfig { const splitTreatments = splitState && splitState.treatments ? splitState.treatments[featureFlagName] : console.error(ERROR_SELECTOR_NO_SPLITSTATE); @@ -37,3 +42,44 @@ export function selectTreatmentWithConfig(splitState: ISplitState, featureFlagNa return treatment ? treatment : defaultValue; } + +/** + * This function extracts a treatment evaluation from the Split state. It returns an object that contains the treatment string value and the status properties of the client: `isReady`, `isReadyFromCache`, `isTimedout`, `hasTimedout`, `isDestroyed`, and `lastUpdate`. + * If a treatment is not found, it returns the default value, which is `'control'` if not specified. + * A treatment is not found if an invalid Split state is passed or if a `getTreatments` action has not been dispatched for the provided feature flag name and key. + * + * @param {ISplitState} splitState + * @param {string} featureFlagName + * @param {SplitIO.SplitKey} key + * @param {string} defaultValue + */ +export function selectTreatmentAndStatus(splitState: ISplitState, featureFlagName: string, key?: SplitIO.SplitKey, defaultValue: string = CONTROL): { + treatment: string +} & IStatus { + const result: any = selectTreatmentWithConfigAndStatus(splitState, featureFlagName, key, { treatment: defaultValue, config: null }); + result.treatment = result.treatment.treatment; + return result; +} + +/** + * This function extracts a treatment evaluation from the Split state. It returns an object that contains the treatment object and the status properties of the client: `isReady`, `isReadyFromCache`, `isTimedout`, `hasTimedout`, `isDestroyed`, and `lastUpdate`. + * If a treatment is not found, it returns the default value as treatment, which is `{ treatment: 'control', configuration: null }` if not specified. + * A treatment is not found if an invalid Split state is passed or if a `getTreatments` action has not been dispatched for the provided feature flag name and key. + * + * @param {ISplitState} splitState + * @param {string} featureFlagName + * @param {SplitIO.SplitKey} key + * @param {SplitIO.TreatmentWithConfig} defaultValue + */ +export function selectTreatmentWithConfigAndStatus(splitState: ISplitState, featureFlagName: string, key?: SplitIO.SplitKey, defaultValue: SplitIO.TreatmentWithConfig = CONTROL_WITH_CONFIG): { + treatment: SplitIO.TreatmentWithConfig +} & IStatus { + const treatment = selectTreatmentWithConfig(splitState, featureFlagName, key, defaultValue); + + const status = getStatus(key); + + return { + ...status, + treatment, + }; +}