Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion .github/workflows/ci-cd.yml
Original file line number Diff line number Diff line change
Expand Up @@ -28,7 +28,7 @@ jobs:
fetch-depth: 0

- name: Set up Node.js
uses: actions/setup-node@v3
uses: actions/setup-node@v4
with:
node-version: 'lts/*'
cache: 'npm'
Expand Down
3 changes: 3 additions & 0 deletions CHANGES.txt
Original file line number Diff line number Diff line change
@@ -1,3 +1,6 @@
2.1.1 (April 8, 2025)
- Bugfixing - Fixed `useSplitClient` and `useSplitTreatments` hooks to properly respect `updateOn<Event>` options. Previously, if the hooks were re-called due to a component re-render, they used the latest version of the SDK client status ignoring when `updateOn<Event>` options were set to `false` and resulting in unexpected changes in treatment values.

2.1.0 (March 28, 2025)
- Added a new optional `properties` argument to the options object of the `useSplitTreatments` hook, allowing to pass a map of properties to append to the generated impressions sent to Split backend. Read more in our docs.
- Updated @splitsoftware/splitio package to version 11.2.0 that includes some minor updates:
Expand Down
3 changes: 2 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -77,6 +77,7 @@ Split has built and maintains SDKs for:
* .NET [Github](https://github.com/splitio/dotnet-client) [Docs](https://help.split.io/hc/en-us/articles/360020240172--NET-SDK)
* Android [Github](https://github.com/splitio/android-client) [Docs](https://help.split.io/hc/en-us/articles/360020343291-Android-SDK)
* Angular [Github](https://github.com/splitio/angular-sdk-plugin) [Docs](https://help.split.io/hc/en-us/articles/6495326064397-Angular-utilities)
* Elixir thin-client [Github](https://github.com/splitio/elixir-thin-client) [Docs](https://help.split.io/hc/en-us/articles/26988707417869-Elixir-Thin-Client-SDK)
* Flutter [Github](https://github.com/splitio/flutter-sdk-plugin) [Docs](https://help.split.io/hc/en-us/articles/8096158017165-Flutter-plugin)
* GO [Github](https://github.com/splitio/go-client) [Docs](https://help.split.io/hc/en-us/articles/360020093652-Go-SDK)
* iOS [Github](https://github.com/splitio/ios-client) [Docs](https://help.split.io/hc/en-us/articles/360020401491-iOS-SDK)
Expand All @@ -96,4 +97,4 @@ For a comprehensive list of open source projects visit our [Github page](https:/

**Learn more about Split:**

Visit [split.io/product](https://www.split.io/product) for an overview of Split, or visit our documentation at [help.split.io](http://help.split.io) for more detailed information.
Visit [split.io/product](https://www.split.io/product) for an overview of Split, or visit our documentation at [help.split.io](https://help.split.io) for more detailed information.
4 changes: 2 additions & 2 deletions package-lock.json

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

2 changes: 1 addition & 1 deletion package.json
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
{
"name": "@splitsoftware/splitio-react",
"version": "2.1.0",
"version": "2.1.1",
"description": "A React library to easily integrate and use Split JS SDK",
"main": "cjs/index.js",
"module": "esm/index.js",
Expand Down
37 changes: 25 additions & 12 deletions src/__tests__/SplitTreatments.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -200,36 +200,37 @@ let renderTimes = 0;
* Tests for asserting that client.getTreatmentsWithConfig and client.getTreatmentsWithConfigByFlagSets are not called unnecessarily when using SplitTreatments and useSplitTreatments.
*/
describe.each([
({ names, flagSets, attributes }: { names?: string[], flagSets?: string[], attributes?: SplitIO.Attributes }) => (
({ names, flagSets, attributes, updateOnSdkUpdate }: { names?: string[], flagSets?: string[], attributes?: SplitIO.Attributes, updateOnSdkUpdate?: boolean }) => (
// @ts-expect-error names and flagSets are mutually exclusive
<SplitTreatments names={names} attributes={attributes} flagSets={flagSets} >
<SplitTreatments names={names} attributes={attributes} flagSets={flagSets} updateOnSdkUpdate={updateOnSdkUpdate} >
{() => {
renderTimes++;
return null;
}}
</SplitTreatments>
),
({ names, flagSets, attributes }: { names?: string[], flagSets?: string[], attributes?: SplitIO.Attributes }) => {
({ names, flagSets, attributes, updateOnSdkUpdate }: { names?: string[], flagSets?: string[], attributes?: SplitIO.Attributes, updateOnSdkUpdate?: boolean }) => {
// @ts-expect-error names and flagSets are mutually exclusive
useSplitTreatments({ names, flagSets, attributes });
useSplitTreatments({ names, flagSets, attributes, updateOnSdkUpdate });
renderTimes++;
return null;
}
])('SplitTreatments & useSplitTreatments optimization', (InnerComponent) => {
let outerFactory = SplitFactory(sdkBrowser);
(outerFactory as any).client().__emitter__.emit(Event.SDK_READY);

function Component({ names, flagSets, attributes, splitKey, clientAttributes }: {
function Component({ names, flagSets, attributes, splitKey, clientAttributes, updateOnSdkUpdate }: {
names?: ISplitTreatmentsProps['names']
flagSets?: ISplitTreatmentsProps['flagSets']
attributes: ISplitTreatmentsProps['attributes']
splitKey: ISplitClientProps['splitKey']
clientAttributes?: ISplitClientProps['attributes']
clientAttributes?: ISplitClientProps['attributes'],
updateOnSdkUpdate?: boolean
}) {
return (
<SplitFactoryProvider factory={outerFactory} >
<SplitClient splitKey={splitKey} updateOnSdkUpdate={true} attributes={clientAttributes} >
<InnerComponent names={names} attributes={attributes} flagSets={flagSets} />
<SplitClient splitKey={splitKey} attributes={clientAttributes} >
<InnerComponent names={names} attributes={attributes} flagSets={flagSets} updateOnSdkUpdate={updateOnSdkUpdate} />
</SplitClient>
</SplitFactoryProvider>
);
Expand Down Expand Up @@ -269,7 +270,7 @@ describe.each([
});

it('rerenders and re-evaluates feature flags if names are not equals (shallow array comparison).', () => {
wrapper.rerender(<Component names={[...names, 'split3']} attributes={{ ...attributes }} splitKey={splitKey} />);
wrapper.rerender(<Component names={[...names, 'split3']} flagSets={flagSets} attributes={attributes} splitKey={splitKey} />);

expect(renderTimes).toBe(2);
expect(outerFactory.client().getTreatmentsWithConfig).toBeCalledTimes(2);
Expand All @@ -290,14 +291,14 @@ describe.each([

it('rerenders and re-evaluates feature flags if attributes are not equals (shallow object comparison).', () => {
const attributesRef = { ...attributes, att2: 'att2' };
wrapper.rerender(<Component names={[...names]} attributes={attributesRef} splitKey={splitKey} />);
wrapper.rerender(<Component names={names} flagSets={flagSets} attributes={attributesRef} splitKey={splitKey} />);

expect(renderTimes).toBe(2);
expect(outerFactory.client().getTreatmentsWithConfig).toBeCalledTimes(2);

// If passing same reference but mutated (bad practice), the component re-renders but doesn't re-evaluate feature flags
attributesRef.att2 = 'att2_val2';
wrapper.rerender(<Component names={[...names]} attributes={attributesRef} splitKey={splitKey} />);
wrapper.rerender(<Component names={names} flagSets={flagSets} attributes={attributesRef} splitKey={splitKey} />);
expect(renderTimes).toBe(3);
expect(outerFactory.client().getTreatmentsWithConfig).toBeCalledTimes(2);
});
Expand All @@ -307,10 +308,11 @@ describe.each([

// State update and split evaluation
act(() => (outerFactory as any).client().__emitter__.emit(Event.SDK_UPDATE));
expect(outerFactory.client().getTreatmentsWithConfig).toBeCalledTimes(2);

// State update after destroy doesn't re-evaluate because the sdk is not operational
(outerFactory as any).client().destroy();
wrapper.rerender(<Component names={names} attributes={attributes} splitKey={splitKey} />);
wrapper.rerender(<Component names={names} flagSets={flagSets} attributes={attributes} splitKey={splitKey} />);

// Updates were batched as a single render, due to automatic batching https://reactjs.org/blog/2022/03/29/react-v18.html#new-feature-automatic-batching
expect(renderTimes).toBe(3);
Expand All @@ -321,6 +323,17 @@ describe.each([
(outerFactory as any).client().__emitter__.emit(Event.SDK_READY);
});

it('rerenders and does not re-evaluate feature flags if lastUpdate timestamp does not change (e.g., SDK_UPDATE event but `updateOnSdkUpdate` false).', () => {
wrapper.rerender(<Component names={names} flagSets={flagSets} attributes={attributes} splitKey={splitKey} updateOnSdkUpdate={false} />);
expect(renderTimes).toBe(2);
expect(outerFactory.client().getTreatmentsWithConfig).toBeCalledTimes(1);

// SDK_UPDATE doesn't re-evaluate due to updateOnSdkUpdate false
act(() => (outerFactory as any).client().__emitter__.emit(Event.SDK_UPDATE));
expect(renderTimes).toBe(3);
expect(outerFactory.client().getTreatmentsWithConfig).toBeCalledTimes(1);
});

it('rerenders and re-evaluates feature flags if client changes.', async () => {
wrapper.rerender(<Component names={names} attributes={attributes} splitKey={'otherKey'} />);
await act(() => (outerFactory as any).client('otherKey').__emitter__.emit(Event.SDK_READY));
Expand Down
84 changes: 67 additions & 17 deletions src/__tests__/useSplitClient.test.tsx
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
import * as React from 'react';
import { act, render } from '@testing-library/react';
import { act, fireEvent, render } from '@testing-library/react';

/** Mocks */
import { mockSdk, Event } from './testUtils/mockSplitFactory';
Expand All @@ -13,7 +13,7 @@ import { sdkBrowser } from './testUtils/sdkConfigs';
import { useSplitClient } from '../useSplitClient';
import { SplitFactoryProvider } from '../SplitFactoryProvider';
import { SplitContext } from '../SplitContext';
import { testAttributesBinding, TestComponentProps } from './testUtils/utils';
import { INITIAL_STATUS, testAttributesBinding, TestComponentProps } from './testUtils/utils';
import { EXCEPTION_NO_SFP } from '../constants';

describe('useSplitClient', () => {
Expand Down Expand Up @@ -87,8 +87,9 @@ describe('useSplitClient', () => {

let countSplitContext = 0, countUseSplitClient = 0, countUseSplitClientUser2 = 0;
let countUseSplitClientWithoutUpdate = 0, countUseSplitClientUser2WithoutTimeout = 0;
let previousLastUpdate = -1;

render(
const { getByTestId } = render(
<SplitFactoryProvider factory={outerFactory} >
<>
<SplitContext.Consumer>
Expand Down Expand Up @@ -129,9 +130,35 @@ describe('useSplitClient', () => {
return null;
})}
{React.createElement(() => {
useSplitClient({ splitKey: sdkBrowser.core.key, updateOnSdkUpdate: false }).client;
const [state, setState] = React.useState(false);

const { isReady, isReadyFromCache, hasTimedout, lastUpdate } = useSplitClient({ splitKey: sdkBrowser.core.key, updateOnSdkUpdate: false });
countUseSplitClientWithoutUpdate++;
return null;
switch (countUseSplitClientWithoutUpdate) {
case 1: // initial render
expect([isReady, isReadyFromCache, hasTimedout]).toEqual([false, false, false]);
expect(lastUpdate).toBe(0);
break;
case 2: // SDK_READY_FROM_CACHE
expect([isReady, isReadyFromCache, hasTimedout]).toEqual([false, true, false]);
expect(lastUpdate).toBeGreaterThan(previousLastUpdate);
break;
case 3: // SDK_READY
expect([isReady, isReadyFromCache, hasTimedout]).toEqual([true, true, false]);
expect(lastUpdate).toBeGreaterThan(previousLastUpdate);
break;
case 4: // Forced re-render, lastUpdate doesn't change after SDK_UPDATE due to updateOnSdkUpdate = false
expect([isReady, isReadyFromCache, hasTimedout]).toEqual([true, true, false]);
expect(lastUpdate).toBe(previousLastUpdate);
break;
default:
throw new Error('Unexpected render');
}

previousLastUpdate = lastUpdate;
return (
<button data-testid="update-button" onClick={() => setState(!state)}>Force Update</button>
);
})}
{React.createElement(() => {
useSplitClient({ splitKey: 'user_2', updateOnSdkTimedout: false });
Expand All @@ -149,6 +176,7 @@ describe('useSplitClient', () => {
act(() => user2Client.__emitter__.emit(Event.SDK_READY));
act(() => mainClient.__emitter__.emit(Event.SDK_UPDATE));
act(() => user2Client.__emitter__.emit(Event.SDK_UPDATE));
act(() => fireEvent.click(getByTestId('update-button')));

// SplitFactoryProvider renders once
expect(countSplitContext).toEqual(1);
Expand All @@ -160,7 +188,7 @@ describe('useSplitClient', () => {
expect(countUseSplitClientUser2).toEqual(5);

// If useSplitClient retrieves the main client and have updateOnSdkUpdate = false, it doesn't render when the main client updates.
expect(countUseSplitClientWithoutUpdate).toEqual(3);
expect(countUseSplitClientWithoutUpdate).toEqual(4);

// If useSplitClient retrieves a different client and have updateOnSdkTimedout = false, it doesn't render when the the new client times out.
expect(countUseSplitClientUser2WithoutTimeout).toEqual(4);
Expand Down Expand Up @@ -194,9 +222,11 @@ describe('useSplitClient', () => {
const mainClient = outerFactory.client() as any;

let rendersCount = 0;
let currentStatus, previousStatus;

function InnerComponent(updateOptions) {
useSplitClient(updateOptions);
previousStatus = currentStatus;
currentStatus = useSplitClient(updateOptions);
rendersCount++;
return null;
}
Expand All @@ -209,26 +239,46 @@ describe('useSplitClient', () => {
)
}

const wrapper = render(<Component updateOnSdkUpdate={false} />);
const wrapper = render(<Component updateOnSdkUpdate={false} updateOnSdkTimedout={false} />);
expect(rendersCount).toBe(1);

act(() => mainClient.__emitter__.emit(Event.SDK_READY)); // trigger re-render
expect(rendersCount).toBe(2);
act(() => mainClient.__emitter__.emit(Event.SDK_READY_TIMED_OUT)); // do not trigger re-render because updateOnSdkTimedout is false
expect(rendersCount).toBe(1);
expect(currentStatus).toMatchObject(INITIAL_STATUS);

act(() => mainClient.__emitter__.emit(Event.SDK_UPDATE)); // do not trigger re-render because updateOnSdkUpdate is false
wrapper.rerender(<Component updateOnSdkUpdate={false} updateOnSdkTimedout={false} />);
expect(rendersCount).toBe(2);
expect(currentStatus).toEqual(previousStatus);

wrapper.rerender(<Component updateOnSdkUpdate={null /** invalid type should default to `true` */} />); // trigger re-render
expect(rendersCount).toBe(3);

act(() => mainClient.__emitter__.emit(Event.SDK_UPDATE)); // trigger re-render because updateOnSdkUpdate is true now
wrapper.rerender(<Component updateOnSdkUpdate={false} updateOnSdkTimedout={true} />); // trigger re-render because there was an SDK_READY_TIMED_OUT event
expect(rendersCount).toBe(4);
expect(currentStatus).toMatchObject({ isReady: false, isReadyFromCache: false, hasTimedout: true });

wrapper.rerender(<Component updateOnSdkUpdate={false} />); // trigger re-render
act(() => mainClient.__emitter__.emit(Event.SDK_READY)); // trigger re-render
expect(rendersCount).toBe(5);
expect(currentStatus).toMatchObject({ isReady: true, isReadyFromCache: false, hasTimedout: true });

act(() => mainClient.__emitter__.emit(Event.SDK_UPDATE)); // do not trigger re-render because updateOnSdkUpdate is false now
act(() => mainClient.__emitter__.emit(Event.SDK_UPDATE)); // do not trigger re-render because updateOnSdkUpdate is false
expect(rendersCount).toBe(5);

wrapper.rerender(<Component updateOnSdkUpdate={false} updateOnSdkTimedout={false} />); // should not update the status (SDK_UPDATE event should be ignored)
expect(rendersCount).toBe(6);
expect(currentStatus).toEqual(previousStatus);

wrapper.rerender(<Component updateOnSdkUpdate={null /** invalid type should default to `true` */} />); // trigger re-render and update the status because updateOnSdkUpdate is true and there was an SDK_UPDATE event
expect(rendersCount).toBe(8);
expect(currentStatus.lastUpdate).toBeGreaterThan(previousStatus.lastUpdate);

act(() => mainClient.__emitter__.emit(Event.SDK_UPDATE)); // trigger re-render because updateOnSdkUpdate is true
expect(rendersCount).toBe(9);
expect(currentStatus.lastUpdate).toBeGreaterThan(previousStatus.lastUpdate);

wrapper.rerender(<Component updateOnSdkUpdate={false} />);
expect(rendersCount).toBe(10);
expect(currentStatus).toEqual(previousStatus);

act(() => mainClient.__emitter__.emit(Event.SDK_UPDATE)); // do not trigger re-render because updateOnSdkUpdate is false now
expect(rendersCount).toBe(10);
});

});
2 changes: 1 addition & 1 deletion src/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -18,7 +18,7 @@ export interface ISplitStatus {

/**
* `isTimedout` indicates if the Split SDK client has triggered an `SDK_READY_TIMED_OUT` event and is not ready to be consumed.
* In other words, `isTimedout` is equivalent to `hasTimeout && !isReady`.
* In other words, `isTimedout` is equivalent to `hasTimedout && !isReady`.
*/
isTimedout: boolean;

Expand Down
13 changes: 9 additions & 4 deletions src/useSplitClient.ts
Original file line number Diff line number Diff line change
Expand Up @@ -31,14 +31,16 @@ export function useSplitClient(options?: IUseSplitClientOptions): ISplitContextV
const context = useSplitContext();
const { client: contextClient, factory } = context;

// @TODO Move `getSplitClient` side effects
// @TODO Move `getSplitClient` side effects and reduce the function cognitive complexity
// @TODO Once `SplitClient` is removed, which updates the context, simplify next line as `const client = factory ? getSplitClient(factory, splitKey) : undefined;`
const client = factory && splitKey ? getSplitClient(factory, splitKey) : contextClient;

initAttributes(client, attributes);

const status = getStatus(client);
const [, setLastUpdate] = React.useState(status.lastUpdate);
const [lastUpdate, setLastUpdate] = React.useState(0);
// `getStatus` is not pure. Its result depends on `client` and `lastUpdate`
// eslint-disable-next-line react-hooks/exhaustive-deps
const status = React.useMemo(() => getStatus(client), [client, lastUpdate]);

// Handle client events
React.useEffect(() => {
Expand Down Expand Up @@ -66,7 +68,10 @@ export function useSplitClient(options?: IUseSplitClientOptions): ISplitContextV
if (!status.hasTimedout) update();
}
}
if (updateOnSdkUpdate !== false) client.on(client.Event.SDK_UPDATE, update);
if (updateOnSdkUpdate !== false) {
client.on(client.Event.SDK_UPDATE, update);
if (statusOnEffect.isReady && statusOnEffect.lastUpdate > status.lastUpdate) update();
}

return () => {
// Unsubscribe from events
Expand Down