Skip to content

Commit 7a46f32

Browse files
Merge pull request #217 from splitio/refactor_for_strict_mode
Refactor for React Strict Mode
2 parents b53791d + 5107906 commit 7a46f32

10 files changed

+149
-148
lines changed

CHANGES.txt

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,6 @@
1-
2.0.1 (November XX, 2024)
1+
2.0.1 (December 4, 2024)
22
- 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".
3+
- Updated implementation of `SplitFactoryProvider` component to support React Strict Mode (Related to https://github.com/splitio/react-client/issues/221).
34
- 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.
45

56
2.0.0 (November 1, 2024)

package-lock.json

Lines changed: 81 additions & 93 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

package.json

Lines changed: 2 additions & 2 deletions
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

Lines changed: 31 additions & 11 deletions
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

Lines changed: 2 additions & 2 deletions
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

Lines changed: 21 additions & 8 deletions
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

Lines changed: 2 additions & 2 deletions
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/useSplitClient.ts

Lines changed: 3 additions & 3 deletions
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,7 +44,7 @@ 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);

src/useTrack.ts

Lines changed: 2 additions & 0 deletions
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
}

src/utils.ts

Lines changed: 3 additions & 26 deletions
Original file line numberDiff line numberDiff line change
@@ -1,15 +1,14 @@
11
import memoizeOne from 'memoize-one';
22
import shallowEqual from 'shallowequal';
3-
import { SplitFactory } from '@splitsoftware/splitio/client';
4-
import { CONTROL_WITH_CONFIG, VERSION, WARN_NAMES_AND_FLAGSETS } from './constants';
3+
import { CONTROL_WITH_CONFIG, WARN_NAMES_AND_FLAGSETS } from './constants';
54
import { ISplitStatus } from './types';
65

76
// Utils used to access singleton instances of Split factories and clients, and to gracefully shutdown all clients together.
87

98
/**
109
* ClientWithContext interface.
1110
*/
12-
export interface IClientWithContext extends SplitIO.IBrowserClient {
11+
interface IClientWithContext extends SplitIO.IBrowserClient {
1312
__getStatus(): {
1413
isReady: boolean;
1514
isReadyFromCache: boolean;
@@ -26,24 +25,6 @@ export interface IFactoryWithLazyInit extends SplitIO.IBrowserSDK {
2625
init(): void;
2726
}
2827

29-
// exported for testing purposes
30-
export const __factories: Map<SplitIO.IBrowserSettings, IFactoryWithLazyInit> = new Map();
31-
32-
// idempotent operation
33-
export function getSplitFactory(config: SplitIO.IBrowserSettings) {
34-
if (!__factories.has(config)) {
35-
// SplitFactory is not an idempotent operation
36-
// @ts-expect-error. 2nd param is not part of type definitions. Used to overwrite the SDK version
37-
const newFactory = SplitFactory(config, (modules) => {
38-
modules.settings.version = VERSION;
39-
modules.lazyInit = true;
40-
}) as IFactoryWithLazyInit;
41-
newFactory.config = config;
42-
__factories.set(config, newFactory);
43-
}
44-
return __factories.get(config) as IFactoryWithLazyInit;
45-
}
46-
4728
// idempotent operation
4829
export function getSplitClient(factory: SplitIO.IBrowserSDK, key?: SplitIO.SplitKey): IClientWithContext {
4930
// factory.client is an idempotent operation
@@ -56,11 +37,6 @@ export function getSplitClient(factory: SplitIO.IBrowserSDK, key?: SplitIO.Split
5637
return client;
5738
}
5839

59-
export function destroySplitFactory(factory: IFactoryWithLazyInit): Promise<void> | undefined {
60-
__factories.delete(factory.config);
61-
return factory.destroy();
62-
}
63-
6440
// Util used to get client status.
6541
// It might be removed in the future, if the JS SDK extends its public API with a `getStatus` method
6642
export function getStatus(client?: SplitIO.IBrowserClient): ISplitStatus {
@@ -79,6 +55,7 @@ export function getStatus(client?: SplitIO.IBrowserClient): ISplitStatus {
7955
/**
8056
* Manage client attributes binding
8157
*/
58+
// @TODO should reset attributes rather than set/merge them, to keep SFP and hooks pure.
8259
export function initAttributes(client?: SplitIO.IBrowserClient, attributes?: SplitIO.Attributes) {
8360
if (client && attributes) client.setAttributes(attributes);
8461
}

0 commit comments

Comments
 (0)