diff --git a/packages/sdk/react-native/__tests__/provider/LDProvider.test.tsx b/packages/sdk/react-native/__tests__/provider/LDProvider.test.tsx index fb208b947..771aef2cd 100644 --- a/packages/sdk/react-native/__tests__/provider/LDProvider.test.tsx +++ b/packages/sdk/react-native/__tests__/provider/LDProvider.test.tsx @@ -1,14 +1,13 @@ import { render } from '@testing-library/react'; +import React from 'react'; import { AutoEnvAttributes, LDContext, LDOptions } from '@launchdarkly/js-client-sdk-common'; import { useLDClient } from '../../src/hooks'; import LDProvider from '../../src/provider/LDProvider'; -import setupListeners from '../../src/provider/setupListeners'; import ReactNativeLDClient from '../../src/ReactNativeLDClient'; jest.mock('../../src/ReactNativeLDClient'); -jest.mock('../../src/provider/setupListeners'); const TestApp = () => { const ldClient = useLDClient(); @@ -22,7 +21,6 @@ const TestApp = () => { }; describe('LDProvider', () => { let ldc: ReactNativeLDClient; - const mockSetupListeners = setupListeners as jest.Mock; beforeEach(() => { jest.useFakeTimers(); @@ -45,9 +43,7 @@ describe('LDProvider', () => { }; }, ); - mockSetupListeners.mockImplementation((client: ReactNativeLDClient, setState: any) => { - setState({ client }); - }); + ldc = new ReactNativeLDClient('mobile-key', AutoEnvAttributes.Enabled); }); diff --git a/packages/sdk/react-native/__tests__/provider/setupListeners.test.ts b/packages/sdk/react-native/__tests__/provider/setupListeners.test.ts deleted file mode 100644 index 98a45a990..000000000 --- a/packages/sdk/react-native/__tests__/provider/setupListeners.test.ts +++ /dev/null @@ -1,34 +0,0 @@ -import { AutoEnvAttributes } from '@launchdarkly/js-client-sdk-common'; - -import setupListeners from '../../src/provider/setupListeners'; -import ReactNativeLDClient from '../../src/ReactNativeLDClient'; - -import resetAllMocks = jest.resetAllMocks; - -jest.mock('../../src/ReactNativeLDClient'); - -describe('setupListeners', () => { - let ldc: ReactNativeLDClient; - let mockSetState: jest.Mock; - - beforeEach(() => { - mockSetState = jest.fn(); - ldc = new ReactNativeLDClient('mob-test-key', AutoEnvAttributes.Enabled); - }); - - afterEach(() => resetAllMocks()); - - test('change listener is setup', () => { - setupListeners(ldc, mockSetState); - expect(ldc.on).toHaveBeenCalledWith('change', expect.any(Function)); - }); - - test('client is set on change event', () => { - setupListeners(ldc, mockSetState); - - const changeHandler = (ldc.on as jest.Mock).mock.calls[0][1]; - changeHandler(); - - expect(mockSetState).toHaveBeenCalledWith({ client: ldc }); - }); -}); diff --git a/packages/sdk/react-native/src/hooks/variation/useTypedVariation.ts b/packages/sdk/react-native/src/hooks/variation/useTypedVariation.ts index cfa1ef965..c9825b797 100644 --- a/packages/sdk/react-native/src/hooks/variation/useTypedVariation.ts +++ b/packages/sdk/react-native/src/hooks/variation/useTypedVariation.ts @@ -1,6 +1,29 @@ +import { useEffect, useRef, useState } from 'react'; + +import type ReactNativeLDClient from '../../ReactNativeLDClient'; import useLDClient from '../useLDClient'; import { LDEvaluationDetailTyped } from './LDEvaluationDetail'; +function getTypedVariation( + ldClient: ReactNativeLDClient, + key: string, + defaultValue: T, +): T { + switch (typeof defaultValue) { + case 'boolean': + return ldClient.boolVariation(key, defaultValue as boolean) as T; + case 'number': + return ldClient.numberVariation(key, defaultValue as number) as T; + case 'string': + return ldClient.stringVariation(key, defaultValue as string) as T; + case 'undefined': + case 'object': + return ldClient.jsonVariation(key, defaultValue) as T; + default: + return ldClient.variation(key, defaultValue); + } +} + /** * Determines the strongly typed variation of a feature flag. * @@ -15,21 +38,72 @@ export const useTypedVariation = defaultValue: T, ): T => { const ldClient = useLDClient(); + const [value, setValue] = useState(() => + ldClient ? getTypedVariation(ldClient, key, defaultValue) : defaultValue, + ); + const valueRef = useRef(value); + + useEffect(() => { + valueRef.current = value; + }, [value]); + + useEffect(() => { + setValue(getTypedVariation(ldClient, key, defaultValue)); + const handleChange = (): void => { + const newValue = getTypedVariation(ldClient, key, defaultValue); + if (newValue !== valueRef.current) { + setValue(newValue); + } + }; + ldClient.on('change', handleChange); + return () => { + ldClient.off('change', handleChange); + }; + }, [key]); + return value; +}; + +function getTypedVariationDetail( + ldClient: ReactNativeLDClient, + key: string, + defaultValue: T, +): LDEvaluationDetailTyped { + let detail: LDEvaluationDetailTyped; switch (typeof defaultValue) { - case 'boolean': - return ldClient.boolVariation(key, defaultValue as boolean) as T; - case 'number': - return ldClient.numberVariation(key, defaultValue as number) as T; - case 'string': - return ldClient.stringVariation(key, defaultValue as string) as T; + case 'boolean': { + detail = ldClient.boolVariationDetail( + key, + defaultValue as boolean, + ) as LDEvaluationDetailTyped; + break; + } + case 'number': { + detail = ldClient.numberVariationDetail( + key, + defaultValue as number, + ) as LDEvaluationDetailTyped; + break; + } + case 'string': { + detail = ldClient.stringVariationDetail( + key, + defaultValue as string, + ) as LDEvaluationDetailTyped; + break; + } case 'undefined': - case 'object': - return ldClient.jsonVariation(key, defaultValue) as T; - default: - return ldClient.variation(key, defaultValue); + case 'object': { + detail = ldClient.jsonVariationDetail(key, defaultValue) as LDEvaluationDetailTyped; + break; + } + default: { + detail = ldClient.variationDetail(key, defaultValue) as LDEvaluationDetailTyped; + break; + } } -}; + return { ...detail, reason: detail.reason ?? null }; +} /** * Determines the strongly typed variation of a feature flag for a context, along with information about @@ -55,48 +129,30 @@ export const useTypedVariationDetail = => { const ldClient = useLDClient(); + const [detail, setDetail] = useState>(() => + ldClient + ? getTypedVariationDetail(ldClient, key, defaultValue) + : { value: defaultValue, reason: null }, + ); + const detailRef = useRef>(detail); - switch (typeof defaultValue) { - case 'boolean': { - const detail = ldClient.boolVariationDetail(key, defaultValue as boolean); - - return { - ...detail, - reason: detail.reason ?? null, - } as LDEvaluationDetailTyped; - } - case 'number': { - const detail = ldClient.numberVariationDetail(key, defaultValue as number); + useEffect(() => { + detailRef.current = detail; + }, [detail]); - return { - ...detail, - reason: detail.reason ?? null, - } as LDEvaluationDetailTyped; - } - case 'string': { - const detail = ldClient.stringVariationDetail(key, defaultValue as string); + useEffect(() => { + setDetail(getTypedVariationDetail(ldClient, key, defaultValue)); + const handleChange = () => { + const newDetail = getTypedVariationDetail(ldClient, key, defaultValue); + if (newDetail.value !== detailRef.current.value) { + setDetail(newDetail); + } + }; + ldClient.on('change', handleChange); + return () => { + ldClient.off('change', handleChange); + }; + }, [key]); - return { - ...detail, - reason: detail.reason ?? null, - } as LDEvaluationDetailTyped; - } - case 'undefined': - case 'object': { - const detail = ldClient.jsonVariationDetail(key, defaultValue); - - return { - ...detail, - reason: detail.reason ?? null, - } as LDEvaluationDetailTyped; - } - default: { - const detail = ldClient.variationDetail(key, defaultValue); - - return { - ...detail, - reason: detail.reason ?? null, - } as LDEvaluationDetailTyped; - } - } + return detail; }; diff --git a/packages/sdk/react-native/src/provider/LDProvider.tsx b/packages/sdk/react-native/src/provider/LDProvider.tsx index 652b84f4a..5a8c433e6 100644 --- a/packages/sdk/react-native/src/provider/LDProvider.tsx +++ b/packages/sdk/react-native/src/provider/LDProvider.tsx @@ -1,8 +1,7 @@ -import React, { PropsWithChildren, useEffect, useState } from 'react'; +import { PropsWithChildren, useMemo } from 'react'; import ReactNativeLDClient from '../ReactNativeLDClient'; -import { Provider, ReactContext } from './reactContext'; -import setupListeners from './setupListeners'; +import { Provider } from './reactContext'; type LDProps = { client: ReactNativeLDClient; @@ -19,13 +18,12 @@ type LDProps = { * @constructor */ const LDProvider = ({ client, children }: PropsWithChildren) => { - const [state, setState] = useState({ client }); + // NOTE: this could only provide marginal benefits, if the provider is + // a child component of a parent that is re-rendering then this + // may still re-render the context value. + const clientContext = useMemo(() => ({ client }), [client]); - useEffect(() => { - setupListeners(client, setState); - }, []); - - return {children}; + return {children}; }; export default LDProvider; diff --git a/packages/sdk/react-native/src/provider/setupListeners.ts b/packages/sdk/react-native/src/provider/setupListeners.ts deleted file mode 100644 index 111e30ce6..000000000 --- a/packages/sdk/react-native/src/provider/setupListeners.ts +++ /dev/null @@ -1,15 +0,0 @@ -import type { Dispatch, SetStateAction } from 'react'; - -import ReactNativeLDClient from '../ReactNativeLDClient'; -import { ReactContext } from './reactContext'; - -const setupListeners = ( - client: ReactNativeLDClient, - setState: Dispatch>, -) => { - client.on('change', () => { - setState({ client }); - }); -}; - -export default setupListeners;