From 689c139c983a217dc9bca5ef3dbb74a2b6f93212 Mon Sep 17 00:00:00 2001 From: Steven Zhang Date: Thu, 29 Jan 2026 18:06:15 -0600 Subject: [PATCH 1/5] fix: react context no longer changes on flag change --- .../__tests__/provider/LDProvider.test.tsx | 8 +- .../__tests__/provider/setupListeners.test.ts | 34 ---- .../src/hooks/variation/useTypedVariation.ts | 160 ++++++++++++------ .../react-native/src/provider/LDProvider.tsx | 16 +- .../src/provider/setupListeners.ts | 15 -- 5 files changed, 117 insertions(+), 116 deletions(-) delete mode 100644 packages/sdk/react-native/__tests__/provider/setupListeners.test.ts delete mode 100644 packages/sdk/react-native/src/provider/setupListeners.ts diff --git a/packages/sdk/react-native/__tests__/provider/LDProvider.test.tsx b/packages/sdk/react-native/__tests__/provider/LDProvider.test.tsx index fb208b947b..771aef2cd4 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 98a45a9900..0000000000 --- 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 cfa1ef9653..c9825b7973 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 652b84f4a2..5a8c433e69 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 111e30ce6d..0000000000 --- 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; From 816740ca1580ace3c48fe127f7ae054033be6c22 Mon Sep 17 00:00:00 2001 From: Steven Zhang Date: Mon, 2 Feb 2026 11:19:28 -0600 Subject: [PATCH 2/5] fix: register changes to defaultValue as a way to trigger hook --- .../sdk/react-native/src/hooks/variation/useTypedVariation.ts | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/packages/sdk/react-native/src/hooks/variation/useTypedVariation.ts b/packages/sdk/react-native/src/hooks/variation/useTypedVariation.ts index c9825b7973..af864aa29d 100644 --- a/packages/sdk/react-native/src/hooks/variation/useTypedVariation.ts +++ b/packages/sdk/react-native/src/hooks/variation/useTypedVariation.ts @@ -59,7 +59,7 @@ export const useTypedVariation = return () => { ldClient.off('change', handleChange); }; - }, [key]); + }, [key, defaultValue]); return value; }; @@ -152,7 +152,7 @@ export const useTypedVariationDetail = { ldClient.off('change', handleChange); }; - }, [key]); + }, [key, defaultValue]); return detail; }; From 54a25b3505d088243e8e97a02c2a8275ea566b8b Mon Sep 17 00:00:00 2001 From: Steven Zhang Date: Mon, 2 Feb 2026 17:58:45 -0600 Subject: [PATCH 3/5] chore: addressing PR comment --- .../react-native/src/hooks/variation/useTypedVariation.ts | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/packages/sdk/react-native/src/hooks/variation/useTypedVariation.ts b/packages/sdk/react-native/src/hooks/variation/useTypedVariation.ts index af864aa29d..9c21b5e00c 100644 --- a/packages/sdk/react-native/src/hooks/variation/useTypedVariation.ts +++ b/packages/sdk/react-native/src/hooks/variation/useTypedVariation.ts @@ -55,9 +55,9 @@ export const useTypedVariation = setValue(newValue); } }; - ldClient.on('change', handleChange); + ldClient.on(`change:${key}`, handleChange); return () => { - ldClient.off('change', handleChange); + ldClient.off(`change:${key}`, handleChange); }; }, [key, defaultValue]); @@ -148,9 +148,9 @@ export const useTypedVariationDetail = { - ldClient.off('change', handleChange); + ldClient.off(`change:${key}`, handleChange); }; }, [key, defaultValue]); From 5669d5d9714c46c095a080532aa4ce359b7eaecc Mon Sep 17 00:00:00 2001 From: Steven Zhang Date: Tue, 3 Feb 2026 10:05:24 -0600 Subject: [PATCH 4/5] chore: removing unnecessary useEffect --- .../src/hooks/variation/useTypedVariation.ts | 22 ++++++++++++------- 1 file changed, 14 insertions(+), 8 deletions(-) diff --git a/packages/sdk/react-native/src/hooks/variation/useTypedVariation.ts b/packages/sdk/react-native/src/hooks/variation/useTypedVariation.ts index 9c21b5e00c..6726066451 100644 --- a/packages/sdk/react-native/src/hooks/variation/useTypedVariation.ts +++ b/packages/sdk/react-native/src/hooks/variation/useTypedVariation.ts @@ -44,14 +44,17 @@ export const useTypedVariation = const valueRef = useRef(value); useEffect(() => { - valueRef.current = value; - }, [value]); + // If the key changes, then we will need to make sure that the value is updated. + const initialValue = getTypedVariation(ldClient, key, defaultValue); + if (valueRef.current !== initialValue) { + valueRef.current = initialValue; + setValue(initialValue); + } - useEffect(() => { - setValue(getTypedVariation(ldClient, key, defaultValue)); const handleChange = (): void => { const newValue = getTypedVariation(ldClient, key, defaultValue); if (newValue !== valueRef.current) { + valueRef.current = newValue; setValue(newValue); } }; @@ -137,14 +140,17 @@ export const useTypedVariationDetail = >(detail); useEffect(() => { - detailRef.current = detail; - }, [detail]); + // If the key changes, then we will need to make sure that the value is updated. + const initialDetail = getTypedVariationDetail(ldClient, key, defaultValue); + if (detailRef.current.value !== initialDetail.value) { + detailRef.current = initialDetail; + setDetail(initialDetail); + } - useEffect(() => { - setDetail(getTypedVariationDetail(ldClient, key, defaultValue)); const handleChange = () => { const newDetail = getTypedVariationDetail(ldClient, key, defaultValue); if (newDetail.value !== detailRef.current.value) { + detailRef.current = newDetail; setDetail(newDetail); } }; From eba484edd0c7e2723aa22421bfb14c8f1b45cd6c Mon Sep 17 00:00:00 2001 From: Steven Zhang Date: Fri, 6 Feb 2026 11:28:26 -0600 Subject: [PATCH 5/5] chore: using deep compare to address objects --- .../src/hooks/variation/useTypedVariation.ts | 10 ++++++---- 1 file changed, 6 insertions(+), 4 deletions(-) diff --git a/packages/sdk/react-native/src/hooks/variation/useTypedVariation.ts b/packages/sdk/react-native/src/hooks/variation/useTypedVariation.ts index 6726066451..27b99e5deb 100644 --- a/packages/sdk/react-native/src/hooks/variation/useTypedVariation.ts +++ b/packages/sdk/react-native/src/hooks/variation/useTypedVariation.ts @@ -1,5 +1,7 @@ import { useEffect, useRef, useState } from 'react'; +import { fastDeepEqual } from '@launchdarkly/js-client-sdk-common'; + import type ReactNativeLDClient from '../../ReactNativeLDClient'; import useLDClient from '../useLDClient'; import { LDEvaluationDetailTyped } from './LDEvaluationDetail'; @@ -46,14 +48,14 @@ export const useTypedVariation = useEffect(() => { // If the key changes, then we will need to make sure that the value is updated. const initialValue = getTypedVariation(ldClient, key, defaultValue); - if (valueRef.current !== initialValue) { + if (!fastDeepEqual(initialValue, valueRef.current)) { valueRef.current = initialValue; setValue(initialValue); } const handleChange = (): void => { const newValue = getTypedVariation(ldClient, key, defaultValue); - if (newValue !== valueRef.current) { + if (!fastDeepEqual(newValue, valueRef.current)) { valueRef.current = newValue; setValue(newValue); } @@ -142,14 +144,14 @@ export const useTypedVariationDetail = { // If the key changes, then we will need to make sure that the value is updated. const initialDetail = getTypedVariationDetail(ldClient, key, defaultValue); - if (detailRef.current.value !== initialDetail.value) { + if (!fastDeepEqual(initialDetail, detailRef.current)) { detailRef.current = initialDetail; setDetail(initialDetail); } const handleChange = () => { const newDetail = getTypedVariationDetail(ldClient, key, defaultValue); - if (newDetail.value !== detailRef.current.value) { + if (!fastDeepEqual(newDetail, detailRef.current)) { detailRef.current = newDetail; setDetail(newDetail); }