diff --git a/.github/workflows/ci-cd.yml b/.github/workflows/ci-cd.yml index 467d201..8a4397b 100644 --- a/.github/workflows/ci-cd.yml +++ b/.github/workflows/ci-cd.yml @@ -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' diff --git a/CHANGES.txt b/CHANGES.txt index a17ad4f..fb80a8e 100644 --- a/CHANGES.txt +++ b/CHANGES.txt @@ -1,3 +1,6 @@ +2.1.1 (April 8, 2025) + - Bugfixing - Fixed `useSplitClient` and `useSplitTreatments` hooks to properly respect `updateOn` 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` 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: diff --git a/README.md b/README.md index 8c11056..6566361 100644 --- a/README.md +++ b/README.md @@ -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) @@ -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. diff --git a/package-lock.json b/package-lock.json index 26cca49..baab405 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "@splitsoftware/splitio-react", - "version": "2.1.0", + "version": "2.1.1", "lockfileVersion": 2, "requires": true, "packages": { "": { "name": "@splitsoftware/splitio-react", - "version": "2.1.0", + "version": "2.1.1", "license": "Apache-2.0", "dependencies": { "@splitsoftware/splitio": "11.2.0", diff --git a/package.json b/package.json index 30c702d..936cb8e 100644 --- a/package.json +++ b/package.json @@ -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", diff --git a/src/__tests__/SplitTreatments.test.tsx b/src/__tests__/SplitTreatments.test.tsx index ef61ad0..4ff0a78 100644 --- a/src/__tests__/SplitTreatments.test.tsx +++ b/src/__tests__/SplitTreatments.test.tsx @@ -200,18 +200,18 @@ 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 - + {() => { renderTimes++; return null; }} ), - ({ 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; } @@ -219,17 +219,18 @@ describe.each([ 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 ( - - + + ); @@ -269,7 +270,7 @@ describe.each([ }); it('rerenders and re-evaluates feature flags if names are not equals (shallow array comparison).', () => { - wrapper.rerender(); + wrapper.rerender(); expect(renderTimes).toBe(2); expect(outerFactory.client().getTreatmentsWithConfig).toBeCalledTimes(2); @@ -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(); + wrapper.rerender(); 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(); + wrapper.rerender(); expect(renderTimes).toBe(3); expect(outerFactory.client().getTreatmentsWithConfig).toBeCalledTimes(2); }); @@ -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(); + wrapper.rerender(); // 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); @@ -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(); + 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(); await act(() => (outerFactory as any).client('otherKey').__emitter__.emit(Event.SDK_READY)); diff --git a/src/__tests__/useSplitClient.test.tsx b/src/__tests__/useSplitClient.test.tsx index 96eee88..e41e69f 100644 --- a/src/__tests__/useSplitClient.test.tsx +++ b/src/__tests__/useSplitClient.test.tsx @@ -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'; @@ -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', () => { @@ -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( <> @@ -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 ( + + ); })} {React.createElement(() => { useSplitClient({ splitKey: 'user_2', updateOnSdkTimedout: false }); @@ -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); @@ -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); @@ -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; } @@ -209,26 +239,46 @@ describe('useSplitClient', () => { ) } - const wrapper = render(); + const wrapper = render(); 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(); expect(rendersCount).toBe(2); + expect(currentStatus).toEqual(previousStatus); - wrapper.rerender(); // trigger re-render - expect(rendersCount).toBe(3); - - act(() => mainClient.__emitter__.emit(Event.SDK_UPDATE)); // trigger re-render because updateOnSdkUpdate is true now + wrapper.rerender(); // 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(); // 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(); // should not update the status (SDK_UPDATE event should be ignored) + expect(rendersCount).toBe(6); + expect(currentStatus).toEqual(previousStatus); + + wrapper.rerender(); // 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(); + 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); }); }); diff --git a/src/types.ts b/src/types.ts index f4eb999..6fb4f8a 100644 --- a/src/types.ts +++ b/src/types.ts @@ -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; diff --git a/src/useSplitClient.ts b/src/useSplitClient.ts index fa8983b..d9344af 100644 --- a/src/useSplitClient.ts +++ b/src/useSplitClient.ts @@ -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(() => { @@ -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