Skip to content

Commit aa90a4a

Browse files
Merge pull request #223 from splitio/development
Release v2.0.1
2 parents 3a01a5c + 1a94593 commit aa90a4a

15 files changed

+210
-166
lines changed

CHANGES.txt

+6
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,9 @@
1+
2.0.1 (December 4, 2024)
2+
- Updated @splitsoftware/splitio package to version 11.0.3 that includes some improvements and bugfixes.
3+
- Updated internal handling of the `updateOnSdkTimedout` param to remove the wrong log "[ERROR] A listener was added for SDK_READY_TIMED_OUT on the SDK, which has already fired and won't be emitted again".
4+
- Updated implementation of `SplitFactoryProvider` component to support React Strict Mode (Related to https://github.com/splitio/react-client/issues/221).
5+
- Bugfixing - Fixed an issue with the `updateOn***` object parameters of the `useSplitClient` and `useSplitTreatments` hooks, and their components and HOCs alternatives, which were not defaulting to `true` when a non-boolean value was provided.
6+
17
2.0.0 (November 1, 2024)
28
- Added support for targeting rules based on large segments.
39
- Added support for passing factory instances to the `factory` prop of the `SplitFactoryProvider` component from other SDK packages that extends the `SplitIO.IBrowserSDK` interface, such as `@splitsoftware/splitio-react-native`, `@splitsoftware/splitio-browserjs` and `@splitsoftware/browser-suite` packages.

package-lock.json

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

package.json

+2-2
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
{
22
"name": "@splitsoftware/splitio-react",
3-
"version": "2.0.0",
3+
"version": "2.0.1",
44
"description": "A React library to easily integrate and use Split JS SDK",
55
"main": "cjs/index.js",
66
"module": "esm/index.js",
@@ -63,7 +63,7 @@
6363
},
6464
"homepage": "https://github.com/splitio/react-client#readme",
6565
"dependencies": {
66-
"@splitsoftware/splitio": "11.0.0",
66+
"@splitsoftware/splitio": "11.0.3",
6767
"memoize-one": "^5.1.1",
6868
"shallowequal": "^1.1.0",
6969
"tslib": "^2.3.1"

src/SplitFactoryProvider.tsx

+31-11
Original file line numberDiff line numberDiff line change
@@ -1,9 +1,21 @@
11
import React from 'react';
22

33
import { ISplitFactoryProviderProps } from './types';
4-
import { WARN_SF_CONFIG_AND_FACTORY } from './constants';
5-
import { getSplitFactory, destroySplitFactory, getSplitClient, getStatus, initAttributes } from './utils';
4+
import { VERSION, WARN_SF_CONFIG_AND_FACTORY } from './constants';
5+
import { getSplitClient, getStatus, initAttributes } from './utils';
66
import { SplitContext } from './SplitContext';
7+
import { SplitFactory } from '@splitsoftware/splitio/client';
8+
9+
/**
10+
* Implementation rationale:
11+
* - Follows React rules: pure components & hooks, with side effects managed in `useEffect`.
12+
* - The `factory` and `client` properties in the context are available from the initial render, rather than being set lazily in a `useEffect`, so that:
13+
* - Hooks retrieve the correct values from the start; for example, `useTrack` accesses the client's `track` method rather than a no-op function (related to https://github.com/splitio/react-client/issues/198).
14+
* - Hooks can support Suspense and Server components where `useEffect` is not called (related to https://github.com/splitio/react-client/issues/192).
15+
* - Re-renders are avoided for child components that do not depend on the factory being ready (e.g., tracking events, updating attributes, or managing consent).
16+
* - `SplitFactoryProvider` updates the context only when props change (`config` or `factory`) but not the state (e.g., client status), preventing unnecessary updates to child components and allowing them to control when to update independently.
17+
* - For these reasons, and to reduce component tree depth, `SplitFactoryProvider` no longer wraps the child component in a `SplitClient` component and thus does not accept a child as a function or `updateOn` props anymore.
18+
*/
719

820
/**
921
* The SplitFactoryProvider is the top level component that provides the Split SDK factory to all child components via the Split Context.
@@ -17,29 +29,37 @@ import { SplitContext } from './SplitContext';
1729
export function SplitFactoryProvider(props: ISplitFactoryProviderProps) {
1830
const { config, factory: propFactory, attributes } = props;
1931

20-
const factory = React.useMemo(() => {
21-
const factory = propFactory || (config ? getSplitFactory(config) : undefined);
22-
initAttributes(factory && factory.client(), attributes);
23-
return factory;
24-
}, [config, propFactory, attributes]);
32+
const factory = React.useMemo<undefined | SplitIO.IBrowserSDK & { init?: () => void }>(() => {
33+
return propFactory ?
34+
propFactory :
35+
config ?
36+
// @ts-expect-error. 2nd param is not part of type definitions. Used to overwrite the SDK version and enable lazy init
37+
SplitFactory(config, (modules) => {
38+
modules.settings.version = VERSION;
39+
modules.lazyInit = true;
40+
}) :
41+
undefined;
42+
}, [config, propFactory]);
43+
2544
const client = factory ? getSplitClient(factory) : undefined;
2645

46+
initAttributes(client, attributes);
47+
2748
// Effect to initialize and destroy the factory when config is provided
2849
React.useEffect(() => {
2950
if (propFactory) {
3051
if (config) console.log(WARN_SF_CONFIG_AND_FACTORY);
3152
return;
3253
}
3354

34-
if (config) {
35-
const factory = getSplitFactory(config);
55+
if (factory) {
3656
factory.init && factory.init();
3757

3858
return () => {
39-
destroySplitFactory(factory);
59+
factory.destroy();
4060
}
4161
}
42-
}, [config, propFactory]);
62+
}, [config, propFactory, factory]);
4363

4464
return (
4565
<SplitContext.Provider value={{ factory, client, ...getStatus(client) }} >

src/__tests__/SplitClient.test.tsx

+2-2
Original file line numberDiff line numberDiff line change
@@ -15,7 +15,7 @@ import { SplitFactoryProvider } from '../SplitFactoryProvider';
1515
import { SplitClient } from '../SplitClient';
1616
import { SplitContext } from '../SplitContext';
1717
import { INITIAL_STATUS, testAttributesBinding, TestComponentProps } from './testUtils/utils';
18-
import { IClientWithContext } from '../utils';
18+
import { getStatus } from '../utils';
1919
import { EXCEPTION_NO_SFP } from '../constants';
2020

2121
describe('SplitClient', () => {
@@ -56,7 +56,7 @@ describe('SplitClient', () => {
5656
client: outerFactory.client(),
5757
isReady: true,
5858
isReadyFromCache: true,
59-
lastUpdate: (outerFactory.client() as IClientWithContext).__getStatus().lastUpdate
59+
lastUpdate: getStatus(outerFactory.client()).lastUpdate
6060
});
6161

6262
return null;

src/__tests__/SplitFactoryProvider.test.tsx

+21-8
Original file line numberDiff line numberDiff line change
@@ -13,14 +13,30 @@ const logSpy = jest.spyOn(console, 'log');
1313
/** Test target */
1414
import { SplitFactoryProvider } from '../SplitFactoryProvider';
1515
import { SplitContext, useSplitContext } from '../SplitContext';
16-
import { __factories, IClientWithContext } from '../utils';
16+
import { getStatus } from '../utils';
1717
import { WARN_SF_CONFIG_AND_FACTORY } from '../constants';
1818
import { INITIAL_STATUS } from './testUtils/utils';
1919
import { useSplitClient } from '../useSplitClient';
2020

2121
describe('SplitFactoryProvider', () => {
2222

23-
test('passes no-ready props to the child if initialized with a config.', () => {
23+
test('passes no-ready properties, no factory and no client to the context if initialized without a config and factory props.', () => {
24+
render(
25+
<SplitFactoryProvider >
26+
{React.createElement(() => {
27+
const context = useSplitContext();
28+
expect(context).toEqual({
29+
...INITIAL_STATUS,
30+
factory: undefined,
31+
client: undefined,
32+
});
33+
return null;
34+
})}
35+
</SplitFactoryProvider>
36+
);
37+
});
38+
39+
test('passes no-ready properties to the context if initialized with a config.', () => {
2440
render(
2541
<SplitFactoryProvider config={sdkBrowser} >
2642
{React.createElement(() => {
@@ -36,7 +52,7 @@ describe('SplitFactoryProvider', () => {
3652
);
3753
});
3854

39-
test('passes ready props to the child if initialized with a ready factory.', async () => {
55+
test('passes ready properties to the context if initialized with a ready factory.', async () => {
4056
const outerFactory = SplitFactory(sdkBrowser);
4157
(outerFactory as any).client().__emitter__.emit(Event.SDK_READY_FROM_CACHE);
4258
(outerFactory as any).client().__emitter__.emit(Event.SDK_READY);
@@ -54,7 +70,7 @@ describe('SplitFactoryProvider', () => {
5470
client: outerFactory.client(),
5571
isReady: true,
5672
isReadyFromCache: true,
57-
lastUpdate: (outerFactory.client() as IClientWithContext).__getStatus().lastUpdate
73+
lastUpdate: getStatus(outerFactory.client()).lastUpdate
5874
});
5975
return null;
6076
})}
@@ -183,8 +199,7 @@ describe('SplitFactoryProvider', () => {
183199

184200
wrapper.unmount();
185201

186-
// Created factories are removed from `factories` cache and `destroy` method is called
187-
expect(__factories.size).toBe(0);
202+
// factory `destroy` methods are called
188203
expect(createdFactories.size).toBe(2);
189204
expect(factoryDestroySpies.length).toBe(2);
190205
factoryDestroySpies.forEach(spy => expect(spy).toBeCalledTimes(1));
@@ -197,8 +212,6 @@ describe('SplitFactoryProvider', () => {
197212
<SplitFactoryProvider factory={outerFactory}>
198213
{React.createElement(() => {
199214
const { factory } = useSplitClient();
200-
// if factory is provided as a prop, `factories` cache is not modified
201-
expect(__factories.size).toBe(0);
202215
destroySpy = jest.spyOn(factory!, 'destroy');
203216
return null;
204217
})}

src/__tests__/SplitTreatments.test.tsx

+2-2
Original file line numberDiff line numberDiff line change
@@ -8,7 +8,7 @@ jest.mock('@splitsoftware/splitio/client', () => {
88
});
99
import { SplitFactory } from '@splitsoftware/splitio/client';
1010
import { sdkBrowser } from './testUtils/sdkConfigs';
11-
import { getStatus, IClientWithContext } from '../utils';
11+
import { getStatus } from '../utils';
1212
import { newSplitFactoryLocalhostInstance } from './testUtils/utils';
1313
import { CONTROL_WITH_CONFIG, EXCEPTION_NO_SFP } from '../constants';
1414

@@ -73,7 +73,7 @@ describe('SplitTreatments', () => {
7373
expect(clientMock.getTreatmentsWithConfig.mock.calls.length).toBe(1);
7474
expect(treatments).toBe(clientMock.getTreatmentsWithConfig.mock.results[0].value);
7575
expect(featureFlagNames).toBe(clientMock.getTreatmentsWithConfig.mock.calls[0][0]);
76-
expect([isReady2, isReadyFromCache, hasTimedout, isTimedout, isDestroyed, lastUpdate]).toStrictEqual([true, false, false, false, false, (outerFactory.client() as IClientWithContext).__getStatus().lastUpdate]);
76+
expect([isReady2, isReadyFromCache, hasTimedout, isTimedout, isDestroyed, lastUpdate]).toStrictEqual([true, false, false, false, false, getStatus(outerFactory.client()).lastUpdate]);
7777
return null;
7878
}}
7979
</SplitTreatments>

src/__tests__/useSplitClient.test.tsx

+2-2
Original file line numberDiff line numberDiff line change
@@ -103,7 +103,7 @@ describe('useSplitClient', () => {
103103
return null;
104104
})}
105105
{React.createElement(() => {
106-
const { client, isReady, isReadyFromCache, hasTimedout } = useSplitClient({ splitKey: 'user_2', updateOnSdkUpdate: true });
106+
const { client, isReady, isReadyFromCache, hasTimedout } = useSplitClient({ splitKey: 'user_2', updateOnSdkUpdate: undefined /* default is true */ });
107107
expect(client).toBe(user2Client);
108108

109109
countUseSplitClientUser2++;
@@ -218,7 +218,7 @@ describe('useSplitClient', () => {
218218
act(() => mainClient.__emitter__.emit(Event.SDK_UPDATE)); // do not trigger re-render because updateOnSdkUpdate is false
219219
expect(rendersCount).toBe(2);
220220

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

224224
act(() => mainClient.__emitter__.emit(Event.SDK_UPDATE)); // trigger re-render because updateOnSdkUpdate is true now

src/__tests__/withSplitTreatments.test.tsx

+29-3
Original file line numberDiff line numberDiff line change
@@ -1,11 +1,12 @@
11
import React from 'react';
2-
import { render } from '@testing-library/react';
2+
import { act, render } from '@testing-library/react';
33

44
/** Mocks */
5-
import { mockSdk } from './testUtils/mockSplitFactory';
5+
import { mockSdk, getLastInstance, Event } from './testUtils/mockSplitFactory';
66
jest.mock('@splitsoftware/splitio/client', () => {
77
return { SplitFactory: mockSdk() };
88
});
9+
import { SplitFactory } from '@splitsoftware/splitio/client';
910
import { sdkBrowser } from './testUtils/sdkConfigs';
1011

1112
/** Test target */
@@ -14,12 +15,13 @@ import { withSplitClient } from '../withSplitClient';
1415
import { withSplitTreatments } from '../withSplitTreatments';
1516
import { getControlTreatmentsWithConfig } from '../utils';
1617

18+
const featureFlagNames = ['split1', 'split2'];
19+
1720
describe('withSplitTreatments', () => {
1821

1922
it(`passes Split props and outer props to the child.
2023
In this test, the value of "props.treatments" is obtained by the function "getControlTreatmentsWithConfig",
2124
and not "client.getTreatmentsWithConfig" since the client is not ready.`, () => {
22-
const featureFlagNames = ['split1', 'split2'];
2325

2426
const Component = withSplitFactory(sdkBrowser)<{ outerProp1: string, outerProp2: number }>(
2527
({ outerProp1, outerProp2, factory }) => {
@@ -51,4 +53,28 @@ describe('withSplitTreatments', () => {
5153
render(<Component outerProp1='outerProp1' outerProp2={2} />);
5254
});
5355

56+
it('disabling "updateOnSdkTimedout" requires passing `false` in all HOCs since the default value is `true`.', () => {
57+
58+
let renderCount = 0;
59+
60+
const Component = withSplitFactory(sdkBrowser)(
61+
withSplitClient(sdkBrowser.core.key)(
62+
withSplitTreatments(featureFlagNames)(
63+
(props) => {
64+
renderCount++;
65+
expect(props.hasTimedout).toBe(false);
66+
67+
return null;
68+
}, undefined, false
69+
), undefined, false
70+
), undefined, false
71+
);
72+
73+
render(<Component />);
74+
75+
act(() => getLastInstance(SplitFactory).client().__emitter__.emit(Event.SDK_READY_TIMED_OUT));
76+
77+
expect(renderCount).toBe(1);
78+
});
79+
5480
});

src/useSplitClient.ts

+13-9
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
import React from 'react';
22
import { useSplitContext } from './SplitContext';
3-
import { getSplitClient, initAttributes, IClientWithContext, getStatus } from './utils';
3+
import { getSplitClient, initAttributes, getStatus } from './utils';
44
import { ISplitContextValues, IUseSplitClientOptions } from './types';
55

66
export const DEFAULT_UPDATE_OPTIONS = {
@@ -33,7 +33,7 @@ export function useSplitClient(options?: IUseSplitClientOptions): ISplitContextV
3333

3434
// @TODO Move `getSplitClient` side effects
3535
// @TODO Once `SplitClient` is removed, which updates the context, simplify next line as `const client = factory ? getSplitClient(factory, splitKey) : undefined;`
36-
const client = factory && splitKey ? getSplitClient(factory, splitKey) : contextClient as IClientWithContext;
36+
const client = factory && splitKey ? getSplitClient(factory, splitKey) : contextClient;
3737

3838
initAttributes(client, attributes);
3939

@@ -44,25 +44,29 @@ export function useSplitClient(options?: IUseSplitClientOptions): ISplitContextV
4444
React.useEffect(() => {
4545
if (!client) return;
4646

47-
const update = () => setLastUpdate(client.__getStatus().lastUpdate);
47+
const update = () => setLastUpdate(getStatus(client).lastUpdate);
4848

4949
// Clients are created on the hook's call, so the status may have changed
5050
const statusOnEffect = getStatus(client);
5151

5252
// Subscribe to SDK events
53-
if (updateOnSdkReady) {
53+
if (updateOnSdkReady !== false) {
5454
if (!statusOnEffect.isReady) client.once(client.Event.SDK_READY, update);
5555
else if (!status.isReady) update();
5656
}
57-
if (updateOnSdkReadyFromCache) {
57+
if (updateOnSdkReadyFromCache !== false) {
5858
if (!statusOnEffect.isReadyFromCache) client.once(client.Event.SDK_READY_FROM_CACHE, update);
5959
else if (!status.isReadyFromCache) update();
6060
}
61-
if (updateOnSdkTimedout) {
62-
if (!statusOnEffect.hasTimedout) client.once(client.Event.SDK_READY_TIMED_OUT, update);
63-
else if (!status.hasTimedout) update();
61+
if (updateOnSdkTimedout !== false) {
62+
if (!statusOnEffect.hasTimedout) {
63+
// Required to avoid error log for event already emitted
64+
if (!statusOnEffect.isReady) client.once(client.Event.SDK_READY_TIMED_OUT, update);
65+
} else {
66+
if (!status.hasTimedout) update();
67+
}
6468
}
65-
if (updateOnSdkUpdate) client.on(client.Event.SDK_UPDATE, update);
69+
if (updateOnSdkUpdate !== false) client.on(client.Event.SDK_UPDATE, update);
6670

6771
return () => {
6872
// Unsubscribe from events

src/useTrack.ts

+2
Original file line numberDiff line numberDiff line change
@@ -13,5 +13,7 @@ const noOpFalse = () => false;
1313
export function useTrack(splitKey?: SplitIO.SplitKey): SplitIO.IBrowserClient['track'] {
1414
// All update options are false to avoid re-renders. The track method doesn't need the client to be operational.
1515
const { client } = useSplitClient({ splitKey, updateOnSdkReady: false, updateOnSdkReadyFromCache: false, updateOnSdkTimedout: false, updateOnSdkUpdate: false });
16+
17+
// Retrieve the client `track` rather than a bound version of it, as there is no need to bind the function, and can be used as a reactive dependency that only changes if the underlying client changes.
1618
return client ? client.track : noOpFalse;
1719
}

0 commit comments

Comments
 (0)