Skip to content

Commit

Permalink
Refactor SFP to fix an issue with React Strict Mode
Browse files Browse the repository at this point in the history
  • Loading branch information
EmilianoSanchez committed Nov 4, 2024
1 parent 4b353cf commit ec3a644
Show file tree
Hide file tree
Showing 7 changed files with 46 additions and 50 deletions.
42 changes: 31 additions & 11 deletions src/SplitFactoryProvider.tsx
Original file line number Diff line number Diff line change
@@ -1,9 +1,21 @@
import React from 'react';

import { ISplitFactoryProviderProps } from './types';
import { WARN_SF_CONFIG_AND_FACTORY } from './constants';
import { getSplitFactory, destroySplitFactory, getSplitClient, getStatus, initAttributes } from './utils';
import { VERSION, WARN_SF_CONFIG_AND_FACTORY } from './constants';
import { getSplitClient, getStatus, initAttributes } from './utils';
import { SplitContext } from './SplitContext';
import { SplitFactory } from '@splitsoftware/splitio/client';

/**
* Implementation rationale:
* - Follows React rules: pure components & hooks, with side effects managed in `useEffect`.
* - The `factory` and `client` properties in the context are available from the initial render, rather than being set lazily in a `useEffect`, so that:
* - 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).
* - Hooks can support Suspense and Server components where `useEffect` is not called (related to https://github.com/splitio/react-client/issues/192).
* - 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).
* - `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.
* - 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.
*/

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

const factory = React.useMemo(() => {
const factory = propFactory || (config ? getSplitFactory(config) : undefined);
initAttributes(factory && factory.client(), attributes);
return factory;
}, [config, propFactory, attributes]);
// Tomar nota de decisiones en RN: SFP con factory inmediato y sin SC
const factory = React.useMemo<undefined | SplitIO.IBrowserSDK & { init?: () => void }>(() => {
return propFactory ?
propFactory :
config ?
// @ts-expect-error. 2nd param is not part of type definitions. Used to overwrite the SDK version and enable lazy init
SplitFactory(config, (modules) => {
modules.settings.version = VERSION;
modules.lazyInit = true;
}) :
undefined;
}, [config, propFactory]);

const client = factory ? getSplitClient(factory) : undefined;
initAttributes(client, attributes);

// Effect to initialize and destroy the factory when config is provided
React.useEffect(() => {
Expand All @@ -31,15 +52,14 @@ export function SplitFactoryProvider(props: ISplitFactoryProviderProps) {
return;
}

if (config) {
const factory = getSplitFactory(config);
if (factory) {
factory.init && factory.init();

return () => {
destroySplitFactory(factory);
factory.destroy();
}
}
}, [config, propFactory]);
}, [config, propFactory, factory]);

return (
<SplitContext.Provider value={{ factory, client, ...getStatus(client) }} >
Expand Down
4 changes: 2 additions & 2 deletions src/__tests__/SplitClient.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -15,7 +15,7 @@ import { SplitFactoryProvider } from '../SplitFactoryProvider';
import { SplitClient } from '../SplitClient';
import { SplitContext } from '../SplitContext';
import { INITIAL_STATUS, testAttributesBinding, TestComponentProps } from './testUtils/utils';
import { IClientWithContext } from '../utils';
import { getStatus } from '../utils';
import { EXCEPTION_NO_SFP } from '../constants';

describe('SplitClient', () => {
Expand Down Expand Up @@ -56,7 +56,7 @@ describe('SplitClient', () => {
client: outerFactory.client(),
isReady: true,
isReadyFromCache: true,
lastUpdate: (outerFactory.client() as IClientWithContext).__getStatus().lastUpdate
lastUpdate: getStatus(outerFactory.client()).lastUpdate
});

return null;
Expand Down
9 changes: 3 additions & 6 deletions src/__tests__/SplitFactoryProvider.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -13,7 +13,7 @@ const logSpy = jest.spyOn(console, 'log');
/** Test target */
import { SplitFactoryProvider } from '../SplitFactoryProvider';
import { SplitContext, useSplitContext } from '../SplitContext';
import { __factories, IClientWithContext } from '../utils';
import { getStatus } from '../utils';
import { WARN_SF_CONFIG_AND_FACTORY } from '../constants';
import { INITIAL_STATUS } from './testUtils/utils';
import { useSplitClient } from '../useSplitClient';
Expand Down Expand Up @@ -54,7 +54,7 @@ describe('SplitFactoryProvider', () => {
client: outerFactory.client(),
isReady: true,
isReadyFromCache: true,
lastUpdate: (outerFactory.client() as IClientWithContext).__getStatus().lastUpdate
lastUpdate: getStatus(outerFactory.client()).lastUpdate
});
return null;
})}
Expand Down Expand Up @@ -183,8 +183,7 @@ describe('SplitFactoryProvider', () => {

wrapper.unmount();

// Created factories are removed from `factories` cache and `destroy` method is called
expect(__factories.size).toBe(0);
// factory `destroy` methods are called
expect(createdFactories.size).toBe(2);
expect(factoryDestroySpies.length).toBe(2);
factoryDestroySpies.forEach(spy => expect(spy).toBeCalledTimes(1));
Expand All @@ -197,8 +196,6 @@ describe('SplitFactoryProvider', () => {
<SplitFactoryProvider factory={outerFactory}>
{React.createElement(() => {
const { factory } = useSplitClient();
// if factory is provided as a prop, `factories` cache is not modified
expect(__factories.size).toBe(0);
destroySpy = jest.spyOn(factory!, 'destroy');
return null;
})}
Expand Down
4 changes: 2 additions & 2 deletions src/__tests__/SplitTreatments.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -8,7 +8,7 @@ jest.mock('@splitsoftware/splitio/client', () => {
});
import { SplitFactory } from '@splitsoftware/splitio/client';
import { sdkBrowser } from './testUtils/sdkConfigs';
import { getStatus, IClientWithContext } from '../utils';
import { getStatus } from '../utils';
import { newSplitFactoryLocalhostInstance } from './testUtils/utils';
import { CONTROL_WITH_CONFIG, EXCEPTION_NO_SFP } from '../constants';

Expand Down Expand Up @@ -73,7 +73,7 @@ describe('SplitTreatments', () => {
expect(clientMock.getTreatmentsWithConfig.mock.calls.length).toBe(1);
expect(treatments).toBe(clientMock.getTreatmentsWithConfig.mock.results[0].value);
expect(featureFlagNames).toBe(clientMock.getTreatmentsWithConfig.mock.calls[0][0]);
expect([isReady2, isReadyFromCache, hasTimedout, isTimedout, isDestroyed, lastUpdate]).toStrictEqual([true, false, false, false, false, (outerFactory.client() as IClientWithContext).__getStatus().lastUpdate]);
expect([isReady2, isReadyFromCache, hasTimedout, isTimedout, isDestroyed, lastUpdate]).toStrictEqual([true, false, false, false, false, getStatus(outerFactory.client()).lastUpdate]);
return null;
}}
</SplitTreatments>
Expand Down
6 changes: 3 additions & 3 deletions src/useSplitClient.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
import React from 'react';
import { useSplitContext } from './SplitContext';
import { getSplitClient, initAttributes, IClientWithContext, getStatus } from './utils';
import { getSplitClient, initAttributes, getStatus } from './utils';
import { ISplitContextValues, IUseSplitClientOptions } from './types';

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

// @TODO Move `getSplitClient` side effects
// @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 as IClientWithContext;
const client = factory && splitKey ? getSplitClient(factory, splitKey) : contextClient;

initAttributes(client, attributes);

Expand All @@ -44,7 +44,7 @@ export function useSplitClient(options?: IUseSplitClientOptions): ISplitContextV
React.useEffect(() => {
if (!client) return;

const update = () => setLastUpdate(client.__getStatus().lastUpdate);
const update = () => setLastUpdate(getStatus(client).lastUpdate);

// Clients are created on the hook's call, so the status may have changed
const statusOnEffect = getStatus(client);
Expand Down
2 changes: 2 additions & 0 deletions src/useTrack.ts
Original file line number Diff line number Diff line change
Expand Up @@ -13,5 +13,7 @@ const noOpFalse = () => false;
export function useTrack(splitKey?: SplitIO.SplitKey): SplitIO.IBrowserClient['track'] {
// All update options are false to avoid re-renders. The track method doesn't need the client to be operational.
const { client } = useSplitClient({ splitKey, updateOnSdkReady: false, updateOnSdkReadyFromCache: false, updateOnSdkTimedout: false, updateOnSdkUpdate: false });

// 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.
return client ? client.track : noOpFalse;
}
29 changes: 3 additions & 26 deletions src/utils.ts
Original file line number Diff line number Diff line change
@@ -1,15 +1,14 @@
import memoizeOne from 'memoize-one';
import shallowEqual from 'shallowequal';
import { SplitFactory } from '@splitsoftware/splitio/client';
import { CONTROL_WITH_CONFIG, VERSION, WARN_NAMES_AND_FLAGSETS } from './constants';
import { CONTROL_WITH_CONFIG, WARN_NAMES_AND_FLAGSETS } from './constants';
import { ISplitStatus } from './types';

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

/**
* ClientWithContext interface.
*/
export interface IClientWithContext extends SplitIO.IBrowserClient {
interface IClientWithContext extends SplitIO.IBrowserClient {
__getStatus(): {
isReady: boolean;
isReadyFromCache: boolean;
Expand All @@ -26,24 +25,6 @@ export interface IFactoryWithLazyInit extends SplitIO.IBrowserSDK {
init(): void;
}

// exported for testing purposes
export const __factories: Map<SplitIO.IBrowserSettings, IFactoryWithLazyInit> = new Map();

// idempotent operation
export function getSplitFactory(config: SplitIO.IBrowserSettings) {
if (!__factories.has(config)) {
// SplitFactory is not an idempotent operation
// @ts-expect-error. 2nd param is not part of type definitions. Used to overwrite the SDK version
const newFactory = SplitFactory(config, (modules) => {
modules.settings.version = VERSION;
modules.lazyInit = true;
}) as IFactoryWithLazyInit;
newFactory.config = config;
__factories.set(config, newFactory);
}
return __factories.get(config) as IFactoryWithLazyInit;
}

// idempotent operation
export function getSplitClient(factory: SplitIO.IBrowserSDK, key?: SplitIO.SplitKey): IClientWithContext {
// factory.client is an idempotent operation
Expand All @@ -56,11 +37,6 @@ export function getSplitClient(factory: SplitIO.IBrowserSDK, key?: SplitIO.Split
return client;
}

export function destroySplitFactory(factory: IFactoryWithLazyInit): Promise<void> | undefined {
__factories.delete(factory.config);
return factory.destroy();
}

// Util used to get client status.
// It might be removed in the future, if the JS SDK extends its public API with a `getStatus` method
export function getStatus(client?: SplitIO.IBrowserClient): ISplitStatus {
Expand All @@ -79,6 +55,7 @@ export function getStatus(client?: SplitIO.IBrowserClient): ISplitStatus {
/**
* Manage client attributes binding
*/
// @TODO should reset attributes rather than set/merge them, to keep SFP and hooks pure.
export function initAttributes(client?: SplitIO.IBrowserClient, attributes?: SplitIO.Attributes) {
if (client && attributes) client.setAttributes(attributes);
}
Expand Down

0 comments on commit ec3a644

Please sign in to comment.