Skip to content

Commit

Permalink
Merge pull request #205 from splitio/react_v2_functional_components
Browse files Browse the repository at this point in the history
[Breaking change] Drop support for React <16.8.0
  • Loading branch information
EmilianoSanchez authored Oct 24, 2024
2 parents e633ad2 + fec84b3 commit 7691dd9
Show file tree
Hide file tree
Showing 8 changed files with 53 additions and 168 deletions.
1 change: 1 addition & 0 deletions CHANGES.txt
Original file line number Diff line number Diff line change
Expand Up @@ -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:
Expand Down
2 changes: 1 addition & 1 deletion package-lock.json

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

4 changes: 2 additions & 2 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down Expand Up @@ -99,7 +99,7 @@
"webpack-merge": "^5.8.0"
},
"peerDependencies": {
"react": ">=16.3.0"
"react": ">=16.8.0"
},
"husky": {
"hooks": {
Expand Down
144 changes: 13 additions & 131 deletions src/SplitClient.tsx
Original file line number Diff line number Diff line change
@@ -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<ISplitComponentProps, ISplitContextValues> {

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<ISplitContextValues>;

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 (
<SplitContext.Provider value={this.state} >
{
typeof children === 'function' ?
children({ ...this.state }) :
children
}
</SplitContext.Provider>
);
}
}
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.
Expand All @@ -131,16 +13,16 @@ export class SplitComponent extends React.Component<ISplitComponentProps, ISplit
* @see {@link https://help.split.io/hc/en-us/articles/360020448791-JavaScript-SDK#advanced-instantiate-multiple-sdk-clients}
*/
export function SplitClient(props: ISplitClientProps) {
const { children } = props;
const context = useSplitClient(props);

return (
<SplitContext.Consumer>
{(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 (
<SplitComponent {...props} factory={factory} client={client} attributes={props.attributes} />
);
}}
</SplitContext.Consumer>
);
<SplitContext.Provider value={context}>
{
typeof children === 'function' ?
children(context) :
children
}
</SplitContext.Provider>
)
}
9 changes: 7 additions & 2 deletions src/SplitFactoryProvider.tsx
Original file line number Diff line number Diff line change
@@ -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,
Expand Down Expand Up @@ -83,6 +84,10 @@ export function SplitFactoryProvider(props: ISplitFactoryProviderProps) {
}, [config, updateOnSdkReady, updateOnSdkReadyFromCache, updateOnSdkTimedout, updateOnSdkUpdate]);

return (
<SplitComponent {...props} factory={factory} client={client} />
<SplitContext.Provider value={{
factory, client, ...getStatus(client)
}} >
<SplitClient {...props} />
</SplitContext.Provider>
);
}
39 changes: 14 additions & 25 deletions src/SplitTreatments.tsx
Original file line number Diff line number Diff line change
@@ -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
Expand All @@ -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<ISplitTreatmentsProps> {

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.Consumer>
{(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,
});
}}
</SplitContext.Consumer>
);
}
return (
<SplitContext.Provider value={context}>
{
children(context)
}
</SplitContext.Provider>
);
}
16 changes: 9 additions & 7 deletions src/__tests__/useSplitClient.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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');
}
Expand All @@ -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.
Expand All @@ -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.
Expand Down
6 changes: 6 additions & 0 deletions src/__tests__/useSplitTreatments.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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<number>();
const lastUpdateSetUser2WithUpdate = new Set<number>();

function validateTreatments({ treatments, isReady, isReadyFromCache }: ISplitTreatmentsChildProps) {
if (isReady || isReadyFromCache) {
Expand Down Expand Up @@ -208,13 +210,15 @@ 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;
})}
{React.createElement(() => {
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;
})}
Expand All @@ -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);
});
Expand Down

0 comments on commit 7691dd9

Please sign in to comment.