Skip to content

Commit e43d534

Browse files
Merge pull request #233 from splitio/development
Release v2.1.1
2 parents b919cb8 + 054513a commit e43d534

File tree

9 files changed

+111
-39
lines changed

9 files changed

+111
-39
lines changed

.github/workflows/ci-cd.yml

+1-1
Original file line numberDiff line numberDiff line change
@@ -28,7 +28,7 @@ jobs:
2828
fetch-depth: 0
2929

3030
- name: Set up Node.js
31-
uses: actions/setup-node@v3
31+
uses: actions/setup-node@v4
3232
with:
3333
node-version: 'lts/*'
3434
cache: 'npm'

CHANGES.txt

+3
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,6 @@
1+
2.1.1 (April 8, 2025)
2+
- 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.
3+
14
2.1.0 (March 28, 2025)
25
- 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.
36
- Updated @splitsoftware/splitio package to version 11.2.0 that includes some minor updates:

README.md

+2-1
Original file line numberDiff line numberDiff line change
@@ -77,6 +77,7 @@ Split has built and maintains SDKs for:
7777
* .NET [Github](https://github.com/splitio/dotnet-client) [Docs](https://help.split.io/hc/en-us/articles/360020240172--NET-SDK)
7878
* Android [Github](https://github.com/splitio/android-client) [Docs](https://help.split.io/hc/en-us/articles/360020343291-Android-SDK)
7979
* Angular [Github](https://github.com/splitio/angular-sdk-plugin) [Docs](https://help.split.io/hc/en-us/articles/6495326064397-Angular-utilities)
80+
* 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)
8081
* Flutter [Github](https://github.com/splitio/flutter-sdk-plugin) [Docs](https://help.split.io/hc/en-us/articles/8096158017165-Flutter-plugin)
8182
* GO [Github](https://github.com/splitio/go-client) [Docs](https://help.split.io/hc/en-us/articles/360020093652-Go-SDK)
8283
* iOS [Github](https://github.com/splitio/ios-client) [Docs](https://help.split.io/hc/en-us/articles/360020401491-iOS-SDK)
@@ -96,4 +97,4 @@ For a comprehensive list of open source projects visit our [Github page](https:/
9697

9798
**Learn more about Split:**
9899

99-
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.
100+
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.

package-lock.json

+2-2
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

package.json

+1-1
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
{
22
"name": "@splitsoftware/splitio-react",
3-
"version": "2.1.0",
3+
"version": "2.1.1",
44
"description": "A React library to easily integrate and use Split JS SDK",
55
"main": "cjs/index.js",
66
"module": "esm/index.js",

src/__tests__/SplitTreatments.test.tsx

+25-12
Original file line numberDiff line numberDiff line change
@@ -200,36 +200,37 @@ let renderTimes = 0;
200200
* Tests for asserting that client.getTreatmentsWithConfig and client.getTreatmentsWithConfigByFlagSets are not called unnecessarily when using SplitTreatments and useSplitTreatments.
201201
*/
202202
describe.each([
203-
({ names, flagSets, attributes }: { names?: string[], flagSets?: string[], attributes?: SplitIO.Attributes }) => (
203+
({ names, flagSets, attributes, updateOnSdkUpdate }: { names?: string[], flagSets?: string[], attributes?: SplitIO.Attributes, updateOnSdkUpdate?: boolean }) => (
204204
// @ts-expect-error names and flagSets are mutually exclusive
205-
<SplitTreatments names={names} attributes={attributes} flagSets={flagSets} >
205+
<SplitTreatments names={names} attributes={attributes} flagSets={flagSets} updateOnSdkUpdate={updateOnSdkUpdate} >
206206
{() => {
207207
renderTimes++;
208208
return null;
209209
}}
210210
</SplitTreatments>
211211
),
212-
({ names, flagSets, attributes }: { names?: string[], flagSets?: string[], attributes?: SplitIO.Attributes }) => {
212+
({ names, flagSets, attributes, updateOnSdkUpdate }: { names?: string[], flagSets?: string[], attributes?: SplitIO.Attributes, updateOnSdkUpdate?: boolean }) => {
213213
// @ts-expect-error names and flagSets are mutually exclusive
214-
useSplitTreatments({ names, flagSets, attributes });
214+
useSplitTreatments({ names, flagSets, attributes, updateOnSdkUpdate });
215215
renderTimes++;
216216
return null;
217217
}
218218
])('SplitTreatments & useSplitTreatments optimization', (InnerComponent) => {
219219
let outerFactory = SplitFactory(sdkBrowser);
220220
(outerFactory as any).client().__emitter__.emit(Event.SDK_READY);
221221

222-
function Component({ names, flagSets, attributes, splitKey, clientAttributes }: {
222+
function Component({ names, flagSets, attributes, splitKey, clientAttributes, updateOnSdkUpdate }: {
223223
names?: ISplitTreatmentsProps['names']
224224
flagSets?: ISplitTreatmentsProps['flagSets']
225225
attributes: ISplitTreatmentsProps['attributes']
226226
splitKey: ISplitClientProps['splitKey']
227-
clientAttributes?: ISplitClientProps['attributes']
227+
clientAttributes?: ISplitClientProps['attributes'],
228+
updateOnSdkUpdate?: boolean
228229
}) {
229230
return (
230231
<SplitFactoryProvider factory={outerFactory} >
231-
<SplitClient splitKey={splitKey} updateOnSdkUpdate={true} attributes={clientAttributes} >
232-
<InnerComponent names={names} attributes={attributes} flagSets={flagSets} />
232+
<SplitClient splitKey={splitKey} attributes={clientAttributes} >
233+
<InnerComponent names={names} attributes={attributes} flagSets={flagSets} updateOnSdkUpdate={updateOnSdkUpdate} />
233234
</SplitClient>
234235
</SplitFactoryProvider>
235236
);
@@ -269,7 +270,7 @@ describe.each([
269270
});
270271

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

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

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

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

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

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

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

315317
// 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
316318
expect(renderTimes).toBe(3);
@@ -321,6 +323,17 @@ describe.each([
321323
(outerFactory as any).client().__emitter__.emit(Event.SDK_READY);
322324
});
323325

326+
it('rerenders and does not re-evaluate feature flags if lastUpdate timestamp does not change (e.g., SDK_UPDATE event but `updateOnSdkUpdate` false).', () => {
327+
wrapper.rerender(<Component names={names} flagSets={flagSets} attributes={attributes} splitKey={splitKey} updateOnSdkUpdate={false} />);
328+
expect(renderTimes).toBe(2);
329+
expect(outerFactory.client().getTreatmentsWithConfig).toBeCalledTimes(1);
330+
331+
// SDK_UPDATE doesn't re-evaluate due to updateOnSdkUpdate false
332+
act(() => (outerFactory as any).client().__emitter__.emit(Event.SDK_UPDATE));
333+
expect(renderTimes).toBe(3);
334+
expect(outerFactory.client().getTreatmentsWithConfig).toBeCalledTimes(1);
335+
});
336+
324337
it('rerenders and re-evaluates feature flags if client changes.', async () => {
325338
wrapper.rerender(<Component names={names} attributes={attributes} splitKey={'otherKey'} />);
326339
await act(() => (outerFactory as any).client('otherKey').__emitter__.emit(Event.SDK_READY));

src/__tests__/useSplitClient.test.tsx

+67-17
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,5 @@
11
import * as React from 'react';
2-
import { act, render } from '@testing-library/react';
2+
import { act, fireEvent, render } from '@testing-library/react';
33

44
/** Mocks */
55
import { mockSdk, Event } from './testUtils/mockSplitFactory';
@@ -13,7 +13,7 @@ import { sdkBrowser } from './testUtils/sdkConfigs';
1313
import { useSplitClient } from '../useSplitClient';
1414
import { SplitFactoryProvider } from '../SplitFactoryProvider';
1515
import { SplitContext } from '../SplitContext';
16-
import { testAttributesBinding, TestComponentProps } from './testUtils/utils';
16+
import { INITIAL_STATUS, testAttributesBinding, TestComponentProps } from './testUtils/utils';
1717
import { EXCEPTION_NO_SFP } from '../constants';
1818

1919
describe('useSplitClient', () => {
@@ -87,8 +87,9 @@ describe('useSplitClient', () => {
8787

8888
let countSplitContext = 0, countUseSplitClient = 0, countUseSplitClientUser2 = 0;
8989
let countUseSplitClientWithoutUpdate = 0, countUseSplitClientUser2WithoutTimeout = 0;
90+
let previousLastUpdate = -1;
9091

91-
render(
92+
const { getByTestId } = render(
9293
<SplitFactoryProvider factory={outerFactory} >
9394
<>
9495
<SplitContext.Consumer>
@@ -129,9 +130,35 @@ describe('useSplitClient', () => {
129130
return null;
130131
})}
131132
{React.createElement(() => {
132-
useSplitClient({ splitKey: sdkBrowser.core.key, updateOnSdkUpdate: false }).client;
133+
const [state, setState] = React.useState(false);
134+
135+
const { isReady, isReadyFromCache, hasTimedout, lastUpdate } = useSplitClient({ splitKey: sdkBrowser.core.key, updateOnSdkUpdate: false });
133136
countUseSplitClientWithoutUpdate++;
134-
return null;
137+
switch (countUseSplitClientWithoutUpdate) {
138+
case 1: // initial render
139+
expect([isReady, isReadyFromCache, hasTimedout]).toEqual([false, false, false]);
140+
expect(lastUpdate).toBe(0);
141+
break;
142+
case 2: // SDK_READY_FROM_CACHE
143+
expect([isReady, isReadyFromCache, hasTimedout]).toEqual([false, true, false]);
144+
expect(lastUpdate).toBeGreaterThan(previousLastUpdate);
145+
break;
146+
case 3: // SDK_READY
147+
expect([isReady, isReadyFromCache, hasTimedout]).toEqual([true, true, false]);
148+
expect(lastUpdate).toBeGreaterThan(previousLastUpdate);
149+
break;
150+
case 4: // Forced re-render, lastUpdate doesn't change after SDK_UPDATE due to updateOnSdkUpdate = false
151+
expect([isReady, isReadyFromCache, hasTimedout]).toEqual([true, true, false]);
152+
expect(lastUpdate).toBe(previousLastUpdate);
153+
break;
154+
default:
155+
throw new Error('Unexpected render');
156+
}
157+
158+
previousLastUpdate = lastUpdate;
159+
return (
160+
<button data-testid="update-button" onClick={() => setState(!state)}>Force Update</button>
161+
);
135162
})}
136163
{React.createElement(() => {
137164
useSplitClient({ splitKey: 'user_2', updateOnSdkTimedout: false });
@@ -149,6 +176,7 @@ describe('useSplitClient', () => {
149176
act(() => user2Client.__emitter__.emit(Event.SDK_READY));
150177
act(() => mainClient.__emitter__.emit(Event.SDK_UPDATE));
151178
act(() => user2Client.__emitter__.emit(Event.SDK_UPDATE));
179+
act(() => fireEvent.click(getByTestId('update-button')));
152180

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

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

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

196224
let rendersCount = 0;
225+
let currentStatus, previousStatus;
197226

198227
function InnerComponent(updateOptions) {
199-
useSplitClient(updateOptions);
228+
previousStatus = currentStatus;
229+
currentStatus = useSplitClient(updateOptions);
200230
rendersCount++;
201231
return null;
202232
}
@@ -209,26 +239,46 @@ describe('useSplitClient', () => {
209239
)
210240
}
211241

212-
const wrapper = render(<Component updateOnSdkUpdate={false} />);
242+
const wrapper = render(<Component updateOnSdkUpdate={false} updateOnSdkTimedout={false} />);
213243
expect(rendersCount).toBe(1);
214244

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

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

221-
wrapper.rerender(<Component updateOnSdkUpdate={null /** invalid type should default to `true` */} />); // trigger re-render
222-
expect(rendersCount).toBe(3);
223-
224-
act(() => mainClient.__emitter__.emit(Event.SDK_UPDATE)); // trigger re-render because updateOnSdkUpdate is true now
253+
wrapper.rerender(<Component updateOnSdkUpdate={false} updateOnSdkTimedout={true} />); // trigger re-render because there was an SDK_READY_TIMED_OUT event
225254
expect(rendersCount).toBe(4);
255+
expect(currentStatus).toMatchObject({ isReady: false, isReadyFromCache: false, hasTimedout: true });
226256

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

230-
act(() => mainClient.__emitter__.emit(Event.SDK_UPDATE)); // do not trigger re-render because updateOnSdkUpdate is false now
261+
act(() => mainClient.__emitter__.emit(Event.SDK_UPDATE)); // do not trigger re-render because updateOnSdkUpdate is false
231262
expect(rendersCount).toBe(5);
263+
264+
wrapper.rerender(<Component updateOnSdkUpdate={false} updateOnSdkTimedout={false} />); // should not update the status (SDK_UPDATE event should be ignored)
265+
expect(rendersCount).toBe(6);
266+
expect(currentStatus).toEqual(previousStatus);
267+
268+
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
269+
expect(rendersCount).toBe(8);
270+
expect(currentStatus.lastUpdate).toBeGreaterThan(previousStatus.lastUpdate);
271+
272+
act(() => mainClient.__emitter__.emit(Event.SDK_UPDATE)); // trigger re-render because updateOnSdkUpdate is true
273+
expect(rendersCount).toBe(9);
274+
expect(currentStatus.lastUpdate).toBeGreaterThan(previousStatus.lastUpdate);
275+
276+
wrapper.rerender(<Component updateOnSdkUpdate={false} />);
277+
expect(rendersCount).toBe(10);
278+
expect(currentStatus).toEqual(previousStatus);
279+
280+
act(() => mainClient.__emitter__.emit(Event.SDK_UPDATE)); // do not trigger re-render because updateOnSdkUpdate is false now
281+
expect(rendersCount).toBe(10);
232282
});
233283

234284
});

src/types.ts

+1-1
Original file line numberDiff line numberDiff line change
@@ -18,7 +18,7 @@ export interface ISplitStatus {
1818

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

src/useSplitClient.ts

+9-4
Original file line numberDiff line numberDiff line change
@@ -31,14 +31,16 @@ export function useSplitClient(options?: IUseSplitClientOptions): ISplitContextV
3131
const context = useSplitContext();
3232
const { client: contextClient, factory } = context;
3333

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

3838
initAttributes(client, attributes);
3939

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

4345
// Handle client events
4446
React.useEffect(() => {
@@ -66,7 +68,10 @@ export function useSplitClient(options?: IUseSplitClientOptions): ISplitContextV
6668
if (!status.hasTimedout) update();
6769
}
6870
}
69-
if (updateOnSdkUpdate !== false) client.on(client.Event.SDK_UPDATE, update);
71+
if (updateOnSdkUpdate !== false) {
72+
client.on(client.Event.SDK_UPDATE, update);
73+
if (statusOnEffect.isReady && statusOnEffect.lastUpdate > status.lastUpdate) update();
74+
}
7075

7176
return () => {
7277
// Unsubscribe from events

0 commit comments

Comments
 (0)