diff --git a/CHANGES.txt b/CHANGES.txt index e72af17..97a2931 100644 --- a/CHANGES.txt +++ b/CHANGES.txt @@ -6,6 +6,7 @@ - Removed deprecated modules: `SplitFactory` component, `useClient`, `useTreatments` and `useManager` hooks, and `withSplitFactory`, `withSplitClient` and `withSplitTreatments` high-order components. Refer to ./MIGRATION-GUIDE.md for instructions on how to migrate to the new alternatives. - Renamed TypeScript interfaces `ISplitFactoryProps` to `ISplitFactoryProviderProps`, and `ISplitFactoryChildProps` to `ISplitFactoryProviderChildProps`. - Renamed `SplitSdk` to `SplitFactory` function, which is the underlying Split SDK factory, i.e., `import { SplitFactory } from '@splitsoftware/splitio'`. + - Dropped support for React below 16.8.0, as the library components where rewritten using the React Hooks API available in React v16.8.0 and above. This refactor simplifies code maintenance and reduces bundle size. 1.13.0 (September 6, 2024) - Updated @splitsoftware/splitio package to version 10.28.0 that includes minor updates: diff --git a/package-lock.json b/package-lock.json index 063dd91..a010821 100644 --- a/package-lock.json +++ b/package-lock.json @@ -45,7 +45,7 @@ "webpack-merge": "^5.8.0" }, "peerDependencies": { - "react": ">=16.3.0" + "react": ">=16.8.0" } }, "node_modules/@aashutoshrathi/word-wrap": { diff --git a/package.json b/package.json index ad946ca..814624a 100644 --- a/package.json +++ b/package.json @@ -26,7 +26,7 @@ "check": "npm run check:lint && npm run check:types", "check:lint": "eslint 'src/**/*.ts*'", "check:types": "tsc --noEmit", - "test": "jest src", + "test": "jest src --silent", "test:watch": "npm test -- --watch", "test:coverage": "jest src --coverage", "test:debug": "node --inspect node_modules/.bin/jest --runInBand", @@ -99,7 +99,7 @@ "webpack-merge": "^5.8.0" }, "peerDependencies": { - "react": ">=16.3.0" + "react": ">=16.8.0" }, "husky": { "hooks": { diff --git a/src/SplitClient.tsx b/src/SplitClient.tsx index af927c4..7f5ebbb 100644 --- a/src/SplitClient.tsx +++ b/src/SplitClient.tsx @@ -1,125 +1,7 @@ import React from 'react'; import { SplitContext } from './SplitContext'; -import { ISplitClientProps, ISplitContextValues } from './types'; -import { getStatus, getSplitClient, initAttributes, IClientWithContext } from './utils'; -import { DEFAULT_UPDATE_OPTIONS } from './useSplitClient'; - -type ISplitComponentProps = ISplitClientProps & { factory: SplitIO.IBrowserSDK | null, client: SplitIO.IBrowserClient | null, attributes?: SplitIO.Attributes }; - -/** - * Common component used to handle the status and events of a Split client passed as prop. - * Reused by both SplitFactoryProvider (main client) and SplitClient (any client) components. - */ -export class SplitComponent extends React.Component { - - static defaultProps = { - children: null, - factory: null, - client: null, - ...DEFAULT_UPDATE_OPTIONS, - } - - // Using `getDerivedStateFromProps` since the state depends on the status of the client in props, which might change over time. - // It could be avoided by removing the client and its status from the component state. - // But it implies to have another instance property to use instead of the state, because we need a unique reference value for SplitContext.Provider - static getDerivedStateFromProps(props: ISplitComponentProps, state: ISplitContextValues) { - const { client, factory, attributes } = props; - // initAttributes can be called in the `render` method too, but it is better here for separation of concerns - initAttributes(client, attributes); - const status = getStatus(client); - // no need to compare status.isTimedout, since it derives from isReady and hasTimedout - if (client !== state.client || - status.isReady !== state.isReady || - status.isReadyFromCache !== state.isReadyFromCache || - status.hasTimedout !== state.hasTimedout || - status.isDestroyed !== state.isDestroyed) { - return { - client, - factory, - ...status, - }; - } - return null; - } - - readonly state: Readonly; - - constructor(props: ISplitComponentProps) { - super(props); - const { factory, client } = props; - - this.state = { - factory, - client, - ...getStatus(client), - }; - } - - // Attach listeners for SDK events, to update state if client status change. - // The listeners take into account the value of `updateOnSdk***` props. - subscribeToEvents(client: SplitIO.IBrowserClient | null) { - if (client) { - const statusOnEffect = getStatus(client); - const status = this.state; - - if (this.props.updateOnSdkReady) { - if (!statusOnEffect.isReady) client.once(client.Event.SDK_READY, this.update); - else if (!status.isReady) this.update(); - } - if (this.props.updateOnSdkReadyFromCache) { - if (!statusOnEffect.isReadyFromCache) client.once(client.Event.SDK_READY_FROM_CACHE, this.update); - else if (!status.isReadyFromCache) this.update(); - } - if (this.props.updateOnSdkTimedout) { - if (!statusOnEffect.hasTimedout) client.once(client.Event.SDK_READY_TIMED_OUT, this.update); - else if (!status.hasTimedout) this.update(); - } - if (this.props.updateOnSdkUpdate) client.on(client.Event.SDK_UPDATE, this.update); - } - } - - unsubscribeFromEvents(client: SplitIO.IBrowserClient | null) { - if (client) { - client.off(client.Event.SDK_READY, this.update); - client.off(client.Event.SDK_READY_FROM_CACHE, this.update); - client.off(client.Event.SDK_READY_TIMED_OUT, this.update); - client.off(client.Event.SDK_UPDATE, this.update); - } - } - - update = () => { - this.setState({ lastUpdate: (this.state.client as IClientWithContext).__getStatus().lastUpdate }); - } - - componentDidMount() { - this.subscribeToEvents(this.props.client); - } - - componentDidUpdate(prevProps: ISplitComponentProps) { - if (this.props.client !== prevProps.client) { - this.unsubscribeFromEvents(prevProps.client); - this.subscribeToEvents(this.props.client); - } - } - - componentWillUnmount() { - // unsubscribe from events, to remove references to SplitClient instance methods - this.unsubscribeFromEvents(this.props.client); - } - - render() { - const { children } = this.props; - return ( - - { - typeof children === 'function' ? - children({ ...this.state }) : - children - } - - ); - } -} +import { ISplitClientProps } from './types'; +import { useSplitClient } from './useSplitClient'; /** * SplitClient will initialize a new SDK client and listen for its events in order to update the Split Context. @@ -131,16 +13,16 @@ export class SplitComponent extends React.Component - {(splitContext: ISplitContextValues) => { - const { factory } = splitContext; - // getSplitClient is idempotent like factory.client: it returns the same client given the same factory, Split Key and TT - const client = factory ? getSplitClient(factory, props.splitKey, props.trafficType) : null; - return ( - - ); - }} - - ); + + { + typeof children === 'function' ? + children(context) : + children + } + + ) } diff --git a/src/SplitFactoryProvider.tsx b/src/SplitFactoryProvider.tsx index 9b7e140..9e370d1 100644 --- a/src/SplitFactoryProvider.tsx +++ b/src/SplitFactoryProvider.tsx @@ -1,10 +1,11 @@ import React from 'react'; -import { SplitComponent } from './SplitClient'; +import { SplitClient } from './SplitClient'; import { ISplitFactoryProviderProps } from './types'; import { WARN_SF_CONFIG_AND_FACTORY } from './constants'; import { getSplitFactory, destroySplitFactory, IFactoryWithClients, getSplitClient, getStatus } from './utils'; import { DEFAULT_UPDATE_OPTIONS } from './useSplitClient'; +import { SplitContext } from './SplitContext'; /** * SplitFactoryProvider will initialize the Split SDK and its main client when `config` prop is provided or updated, listen for its events in order to update the Split Context, @@ -83,6 +84,10 @@ export function SplitFactoryProvider(props: ISplitFactoryProviderProps) { }, [config, updateOnSdkReady, updateOnSdkReadyFromCache, updateOnSdkTimedout, updateOnSdkUpdate]); return ( - + + + ); } diff --git a/src/SplitTreatments.tsx b/src/SplitTreatments.tsx index da803c3..43142af 100644 --- a/src/SplitTreatments.tsx +++ b/src/SplitTreatments.tsx @@ -1,7 +1,8 @@ import React from 'react'; + import { SplitContext } from './SplitContext'; -import { ISplitTreatmentsProps, ISplitContextValues } from './types'; -import { memoizeGetTreatmentsWithConfig } from './utils'; +import { ISplitTreatmentsProps } from './types'; +import { useSplitTreatments } from './useSplitTreatments'; /** * SplitTreatments accepts a list of feature flag names and optional attributes. It accesses the client at SplitContext to @@ -10,28 +11,16 @@ import { memoizeGetTreatmentsWithConfig } from './utils'; * * @see {@link https://help.split.io/hc/en-us/articles/360020448791-JavaScript-SDK#get-treatments-with-configurations} */ -export class SplitTreatments extends React.Component { - - private logWarning?: boolean; - - // Using a memoized `client.getTreatmentsWithConfig` function to avoid duplicated impressions - private evaluateFeatureFlags = memoizeGetTreatmentsWithConfig(); - - render() { - const { names, flagSets, children, attributes } = this.props; - - return ( - - {(splitContext: ISplitContextValues) => { - const { client, lastUpdate } = splitContext; - const treatments = this.evaluateFeatureFlags(client, lastUpdate, names, attributes, client ? { ...client.getAttributes() } : {}, flagSets); +export function SplitTreatments(props: ISplitTreatmentsProps) { + const { children } = props; + // SplitTreatments doesn't update on SDK events, since it is inside SplitFactory and/or SplitClient. + const context = useSplitTreatments({ ...props, updateOnSdkReady: false, updateOnSdkReadyFromCache: false }); - // SplitTreatments only accepts a function as a child, not a React Element (JSX) - return children({ - ...splitContext, treatments, - }); - }} - - ); - } + return ( + + { + children(context) + } + + ); } diff --git a/src/__tests__/useSplitClient.test.tsx b/src/__tests__/useSplitClient.test.tsx index b706b1f..c74f463 100644 --- a/src/__tests__/useSplitClient.test.tsx +++ b/src/__tests__/useSplitClient.test.tsx @@ -163,19 +163,20 @@ describe('useSplitClient', () => { countNestedComponent++; switch (countNestedComponent) { case 1: + case 2: expect(status.isReady).toBe(false); expect(status.isReadyFromCache).toBe(false); break; - case 2: + case 3: + case 4: expect(status.isReady).toBe(false); expect(status.isReadyFromCache).toBe(true); break; - case 3: + case 5: + case 6: expect(status.isReady).toBe(true); expect(status.isReadyFromCache).toBe(true); break; - case 4: - break; default: throw new Error('Unexpected render'); } @@ -187,11 +188,11 @@ describe('useSplitClient', () => { ); 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(() => mainClient.__emitter__.emit(Event.SDK_READY)); act(() => user2Client.__emitter__.emit(Event.SDK_READY_TIMED_OUT)); act(() => user2Client.__emitter__.emit(Event.SDK_READY)); + act(() => mainClient.__emitter__.emit(Event.SDK_UPDATE)); act(() => user2Client.__emitter__.emit(Event.SDK_UPDATE)); // SplitContext renders 3 times: initially, when ready from cache, and when ready. @@ -217,7 +218,8 @@ describe('useSplitClient', () => { expect(countSplitClientUser2WithUpdate).toEqual(countSplitContext + 3); expect(countUseSplitClientUser2WithTimeout).toEqual(countSplitContext + 3); - expect(countNestedComponent).toEqual(4); + // A component using useSplitClient inside SplitClient, renders twice per SDK event + expect(countNestedComponent).toEqual(6); }); // Remove this test once side effects are moved to the useSplitClient effect. diff --git a/src/__tests__/useSplitTreatments.test.tsx b/src/__tests__/useSplitTreatments.test.tsx index abc5454..41cf801 100644 --- a/src/__tests__/useSplitTreatments.test.tsx +++ b/src/__tests__/useSplitTreatments.test.tsx @@ -169,6 +169,8 @@ describe('useSplitTreatments', () => { const user2Client = outerFactory.client('user_2') as any; let countSplitContext = 0, countSplitTreatments = 0, countUseSplitTreatments = 0, countUseSplitTreatmentsUser2 = 0, countUseSplitTreatmentsUser2WithUpdate = 0; + const lastUpdateSetUser2 = new Set(); + const lastUpdateSetUser2WithUpdate = new Set(); function validateTreatments({ treatments, isReady, isReadyFromCache }: ISplitTreatmentsChildProps) { if (isReady || isReadyFromCache) { @@ -208,6 +210,7 @@ describe('useSplitTreatments', () => { const context = useSplitTreatments({ names: ['split_test'], splitKey: 'user_2' }); expect(context.client).toBe(user2Client); validateTreatments(context); + lastUpdateSetUser2.add(context.lastUpdate); countUseSplitTreatmentsUser2++; return null; })} @@ -215,6 +218,7 @@ describe('useSplitTreatments', () => { const context = useSplitTreatments({ names: ['split_test'], splitKey: 'user_2', updateOnSdkUpdate: true }); expect(context.client).toBe(user2Client); validateTreatments(context); + lastUpdateSetUser2WithUpdate.add(context.lastUpdate); countUseSplitTreatmentsUser2WithUpdate++; return null; })} @@ -240,8 +244,10 @@ describe('useSplitTreatments', () => { // If useSplitTreatments uses a different client than the context one, it renders when the context renders and when the new client is ready and ready from cache. expect(countUseSplitTreatmentsUser2).toEqual(countSplitContext + 2); + expect(lastUpdateSetUser2.size).toEqual(3); // If it is used with `updateOnSdkUpdate: true`, it also renders when the client emits an SDK_UPDATE event. expect(countUseSplitTreatmentsUser2WithUpdate).toEqual(countSplitContext + 3); + expect(lastUpdateSetUser2WithUpdate.size).toEqual(4); expect(user2Client.getTreatmentsWithConfig).toHaveBeenCalledTimes(5); expect(user2Client.getTreatmentsWithConfig).toHaveBeenLastCalledWith(['split_test'], undefined); });