From f0dc2914f7c86af45bd5ce14dc33265b728bdf6b Mon Sep 17 00:00:00 2001 From: Steven Zhang Date: Mon, 9 Mar 2026 19:18:41 -0500 Subject: [PATCH 1/7] chore: adding deprecated flag hooks --- .../__tests__/client/hooks/renderHelpers.tsx | 37 ++ .../__tests__/client/hooks/useFlag.test.tsx | 311 +++++++++++++++++ .../client/hooks/useFlagDetail.test.tsx | 326 ++++++++++++++++++ .../__tests__/client/hooks/useFlags.test.tsx | 184 ++++++++++ .../src/client/deprecated-hooks/useFlag.ts | 50 +++ .../client/deprecated-hooks/useFlagDetail.ts | 57 +++ .../src/client/deprecated-hooks/useFlags.ts | 6 +- 7 files changed, 967 insertions(+), 4 deletions(-) create mode 100644 packages/sdk/react/__tests__/client/hooks/renderHelpers.tsx create mode 100644 packages/sdk/react/__tests__/client/hooks/useFlag.test.tsx create mode 100644 packages/sdk/react/__tests__/client/hooks/useFlagDetail.test.tsx create mode 100644 packages/sdk/react/__tests__/client/hooks/useFlags.test.tsx create mode 100644 packages/sdk/react/src/client/deprecated-hooks/useFlag.ts create mode 100644 packages/sdk/react/src/client/deprecated-hooks/useFlagDetail.ts diff --git a/packages/sdk/react/__tests__/client/hooks/renderHelpers.tsx b/packages/sdk/react/__tests__/client/hooks/renderHelpers.tsx new file mode 100644 index 000000000..b4ce8ccec --- /dev/null +++ b/packages/sdk/react/__tests__/client/hooks/renderHelpers.tsx @@ -0,0 +1,37 @@ +import React from 'react'; + +import { LDReactClientContextValue } from '../../../src/client/LDClient'; +import { LDReactContext } from '../../../src/client/provider/LDReactContext'; +import { makeMockClient } from '../mockClient'; + +export function makeWrapper(mockClient: ReturnType) { + const contextValue: LDReactClientContextValue = { + client: mockClient, + initializedState: 'unknown', + }; + + return function Wrapper({ children }: { children: React.ReactNode }) { + return {children}; + }; +} + +/** + * Creates a wrapper whose context value can be updated after render. + * `setterRef.current` is set on first render and can be called inside `act()`. + */ +export function makeStatefulWrapper(mockClient: ReturnType) { + const setterRef = { + current: null as React.Dispatch> | null, + }; + + function Wrapper({ children }: { children: React.ReactNode }) { + const [ctxValue, setCtx] = React.useState({ + client: mockClient, + initializedState: 'complete', + }); + setterRef.current = setCtx; + return {children}; + } + + return { Wrapper, setterRef }; +} diff --git a/packages/sdk/react/__tests__/client/hooks/useFlag.test.tsx b/packages/sdk/react/__tests__/client/hooks/useFlag.test.tsx new file mode 100644 index 000000000..5f76ea265 --- /dev/null +++ b/packages/sdk/react/__tests__/client/hooks/useFlag.test.tsx @@ -0,0 +1,311 @@ +/** + * @jest-environment jsdom + */ +import { act, render } from '@testing-library/react'; +import React from 'react'; + +import { useFlag } from '../../../src/client/deprecated-hooks/useFlag'; +import { makeMockClient } from '../mockClient'; +import { makeStatefulWrapper, makeWrapper } from './renderHelpers'; + +it('dispatches to boolVariation for boolean defaultValue', () => { + const mockClient = makeMockClient(); + (mockClient.boolVariation as jest.Mock).mockReturnValue(true); + + const captured: boolean[] = []; + + function FlagConsumer() { + const value = useFlag('my-flag', false); + captured.push(value); + return null; + } + + const Wrapper = makeWrapper(mockClient); + render( + + + , + ); + + expect(mockClient.boolVariation).toHaveBeenCalledWith('my-flag', false); + expect(captured[0]).toBe(true); +}); + +it('dispatches to stringVariation for string defaultValue', () => { + const mockClient = makeMockClient(); + (mockClient.stringVariation as jest.Mock).mockReturnValue('hello'); + + const captured: string[] = []; + + function FlagConsumer() { + const value = useFlag('my-flag', 'default'); + captured.push(value); + return null; + } + + const Wrapper = makeWrapper(mockClient); + render( + + + , + ); + + expect(mockClient.stringVariation).toHaveBeenCalledWith('my-flag', 'default'); + expect(captured[0]).toBe('hello'); +}); + +it('dispatches to numberVariation for number defaultValue', () => { + const mockClient = makeMockClient(); + (mockClient.numberVariation as jest.Mock).mockReturnValue(42); + + const captured: number[] = []; + + function FlagConsumer() { + const value = useFlag('my-flag', 0); + captured.push(value); + return null; + } + + const Wrapper = makeWrapper(mockClient); + render( + + + , + ); + + expect(mockClient.numberVariation).toHaveBeenCalledWith('my-flag', 0); + expect(captured[0]).toBe(42); +}); + +it('dispatches to jsonVariation for object defaultValue', () => { + const mockClient = makeMockClient(); + const result = { enabled: true }; + (mockClient.jsonVariation as jest.Mock).mockReturnValue(result); + + const captured: object[] = []; + + function FlagConsumer() { + const value = useFlag('my-flag', {}); + captured.push(value); + return null; + } + + const Wrapper = makeWrapper(mockClient); + render( + + + , + ); + + expect(mockClient.jsonVariation).toHaveBeenCalledWith('my-flag', {}); + expect(captured[0]).toEqual(result); +}); + +it('subscribes to change: on mount and unsubscribes on unmount', () => { + const mockClient = makeMockClient(); + + function FlagConsumer() { + useFlag('my-flag', false); + return null; + } + + const Wrapper = makeWrapper(mockClient); + const { unmount } = render( + + + , + ); + + expect(mockClient.on).toHaveBeenCalledWith('change:my-flag', expect.any(Function)); + + const onCall = (mockClient.on as jest.Mock).mock.calls.find( + ([event]: [string]) => event === 'change:my-flag', + ); + const handler = onCall?.[1]; + + unmount(); + + expect(mockClient.off).toHaveBeenCalledWith('change:my-flag', handler); +}); + +it('re-renders with new value when change: fires', async () => { + const mockClient = makeMockClient(); + (mockClient.boolVariation as jest.Mock).mockReturnValue(false); + + const captured: boolean[] = []; + + function FlagConsumer() { + const value = useFlag('my-flag', false); + captured.push(value); + return null; + } + + const Wrapper = makeWrapper(mockClient); + render( + + + , + ); + + expect(captured[captured.length - 1]).toBe(false); + + (mockClient.boolVariation as jest.Mock).mockReturnValue(true); + + await act(async () => { + mockClient.emitFlagChange('my-flag'); + }); + + expect(captured[captured.length - 1]).toBe(true); +}); + +it('does not re-render when a different flag key changes', async () => { + const mockClient = makeMockClient(); + (mockClient.boolVariation as jest.Mock).mockReturnValue(false); + + let renderCount = 0; + + function FlagConsumer() { + useFlag('flag-a', false); + renderCount += 1; + return null; + } + + const Wrapper = makeWrapper(mockClient); + render( + + + , + ); + + const initialRenders = renderCount; + + await act(async () => { + mockClient.emitFlagChange('flag-b'); + }); + + expect(renderCount).toBe(initialRenders); +}); + +it('does not re-subscribe when parent re-renders with an inline object defaultValue', () => { + const mockClient = makeMockClient(); + (mockClient.jsonVariation as jest.Mock).mockReturnValue({ enabled: false }); + + let setParentState!: (n: number) => void; + + const Wrapper = makeWrapper(mockClient); + + function FlagConsumer({ n: _ }: { n: number }) { + useFlag('my-flag', {}); + return null; + } + + function Parent() { + const [n, setN] = React.useState(0); + setParentState = setN; + return ( + + + + ); + } + + render(); + + const onCallsBefore = (mockClient.on as jest.Mock).mock.calls.length; + + act(() => { + setParentState(1); + }); + act(() => { + setParentState(2); + }); + + expect((mockClient.on as jest.Mock).mock.calls.length).toBe(onCallsBefore); +}); + +it('calls variation again when context changes after identify', () => { + const mockClient = makeMockClient(); + (mockClient.boolVariation as jest.Mock).mockReturnValue(true); + + const { Wrapper: StatefulWrapper, setterRef } = makeStatefulWrapper(mockClient); + + function FlagConsumer() { + useFlag('my-flag', false); + return null; + } + + render( + + + , + ); + + const callsBefore = (mockClient.boolVariation as jest.Mock).mock.calls.length; + + act(() => { + setterRef.current!({ + client: mockClient, + context: { kind: 'user', key: 'new-user' }, + initializedState: 'complete', + }); + }); + + expect((mockClient.boolVariation as jest.Mock).mock.calls.length).toBeGreaterThan(callsBefore); +}); + +it('logs a deprecation warning on mount via client.logger.warn', async () => { + const mockClient = makeMockClient(); + const Wrapper = makeWrapper(mockClient); + + function FlagConsumer() { + useFlag('my-flag', false); + return null; + } + + await act(async () => { + render( + + + , + ); + }); + + expect(mockClient.logger.warn).toHaveBeenCalledWith( + expect.stringContaining('[LaunchDarkly] useFlag is deprecated'), + ); +}); + +it('updates value immediately when key changes without waiting for change event', () => { + const mockClient = makeMockClient(); + (mockClient.boolVariation as jest.Mock).mockImplementation((key: string, def: boolean) => { + if (key === 'flag-a') return false; + if (key === 'flag-b') return true; + return def; + }); + + const captured: boolean[] = []; + + function FlagConsumer({ flagKey }: { flagKey: string }) { + const value = useFlag(flagKey, false); + captured.push(value); + return null; + } + + const Wrapper = makeWrapper(mockClient); + const { rerender } = render( + + + , + ); + + expect(captured[captured.length - 1]).toBe(false); + + rerender( + + + , + ); + + expect(captured[captured.length - 1]).toBe(true); + expect(mockClient.boolVariation).toHaveBeenCalledWith('flag-b', false); +}); diff --git a/packages/sdk/react/__tests__/client/hooks/useFlagDetail.test.tsx b/packages/sdk/react/__tests__/client/hooks/useFlagDetail.test.tsx new file mode 100644 index 000000000..8a7974cdc --- /dev/null +++ b/packages/sdk/react/__tests__/client/hooks/useFlagDetail.test.tsx @@ -0,0 +1,326 @@ +/** + * @jest-environment jsdom + */ +import { act, render } from '@testing-library/react'; +import React from 'react'; + +import type { LDEvaluationDetailTyped } from '@launchdarkly/js-client-sdk'; + +import { useFlagDetail } from '../../../src/client/deprecated-hooks/useFlagDetail'; +import { makeMockClient } from '../mockClient'; +import { makeStatefulWrapper, makeWrapper } from './renderHelpers'; + +it('dispatches to boolVariationDetail for boolean defaultValue', () => { + const mockClient = makeMockClient(); + const detail: LDEvaluationDetailTyped = { + value: true, + variationIndex: 0, + reason: { kind: 'OFF' }, + }; + (mockClient.boolVariationDetail as jest.Mock).mockReturnValue(detail); + + const captured: LDEvaluationDetailTyped[] = []; + + function FlagConsumer() { + const d = useFlagDetail('my-flag', false); + captured.push(d); + return null; + } + + const Wrapper = makeWrapper(mockClient); + render( + + + , + ); + + expect(mockClient.boolVariationDetail).toHaveBeenCalledWith('my-flag', false); + expect(captured[0]).toEqual(detail); +}); + +it('dispatches to stringVariationDetail for string defaultValue', () => { + const mockClient = makeMockClient(); + const detail: LDEvaluationDetailTyped = { + value: 'on', + variationIndex: 1, + reason: { kind: 'RULE_MATCH', ruleIndex: 0, ruleId: 'r1' }, + }; + (mockClient.stringVariationDetail as jest.Mock).mockReturnValue(detail); + + const captured: LDEvaluationDetailTyped[] = []; + + function FlagConsumer() { + const d = useFlagDetail('my-flag', 'off'); + captured.push(d); + return null; + } + + const Wrapper = makeWrapper(mockClient); + render( + + + , + ); + + expect(mockClient.stringVariationDetail).toHaveBeenCalledWith('my-flag', 'off'); + expect(captured[0]).toEqual(detail); +}); + +it('dispatches to numberVariationDetail for number defaultValue', () => { + const mockClient = makeMockClient(); + const detail: LDEvaluationDetailTyped = { + value: 99, + variationIndex: 2, + reason: { kind: 'FALLTHROUGH' }, + }; + (mockClient.numberVariationDetail as jest.Mock).mockReturnValue(detail); + + const captured: LDEvaluationDetailTyped[] = []; + + function FlagConsumer() { + const d = useFlagDetail('my-flag', 0); + captured.push(d); + return null; + } + + const Wrapper = makeWrapper(mockClient); + render( + + + , + ); + + expect(mockClient.numberVariationDetail).toHaveBeenCalledWith('my-flag', 0); + expect(captured[0]).toEqual(detail); +}); + +it('dispatches to jsonVariationDetail for object defaultValue', () => { + const mockClient = makeMockClient(); + const detail: LDEvaluationDetailTyped = { + value: { x: 1 }, + variationIndex: 0, + reason: { kind: 'OFF' }, + }; + (mockClient.jsonVariationDetail as jest.Mock).mockReturnValue(detail); + + const captured: LDEvaluationDetailTyped[] = []; + + function FlagConsumer() { + const d = useFlagDetail('my-flag', {}); + captured.push(d); + return null; + } + + const Wrapper = makeWrapper(mockClient); + render( + + + , + ); + + expect(mockClient.jsonVariationDetail).toHaveBeenCalledWith('my-flag', {}); + expect(captured[0]).toEqual(detail); +}); + +it('subscribes to change: on mount and unsubscribes on unmount', () => { + const mockClient = makeMockClient(); + (mockClient.boolVariationDetail as jest.Mock).mockReturnValue({ + value: false, + variationIndex: 0, + reason: { kind: 'OFF' }, + }); + + function FlagConsumer() { + useFlagDetail('my-flag', false); + return null; + } + + const Wrapper = makeWrapper(mockClient); + const { unmount } = render( + + + , + ); + + expect(mockClient.on).toHaveBeenCalledWith('change:my-flag', expect.any(Function)); + + const onCall = (mockClient.on as jest.Mock).mock.calls.find( + ([event]: [string]) => event === 'change:my-flag', + ); + const handler = onCall?.[1]; + + unmount(); + + expect(mockClient.off).toHaveBeenCalledWith('change:my-flag', handler); +}); + +it('re-renders with updated detail when change: fires', async () => { + const mockClient = makeMockClient(); + const initialDetail: LDEvaluationDetailTyped = { + value: false, + variationIndex: 0, + reason: { kind: 'OFF' }, + }; + const updatedDetail: LDEvaluationDetailTyped = { + value: true, + variationIndex: 1, + reason: { kind: 'FALLTHROUGH' }, + }; + (mockClient.boolVariationDetail as jest.Mock).mockReturnValue(initialDetail); + + const captured: LDEvaluationDetailTyped[] = []; + + function FlagConsumer() { + const d = useFlagDetail('my-flag', false); + captured.push(d); + return null; + } + + const Wrapper = makeWrapper(mockClient); + render( + + + , + ); + + expect(captured[captured.length - 1]).toEqual(initialDetail); + + (mockClient.boolVariationDetail as jest.Mock).mockReturnValue(updatedDetail); + + await act(async () => { + mockClient.emitFlagChange('my-flag'); + }); + + expect(captured[captured.length - 1]).toEqual(updatedDetail); +}); + +it('does not loop infinitely when defaultValue is an inline object', async () => { + const mockClient = makeMockClient(); + (mockClient.jsonVariationDetail as jest.Mock).mockReturnValue({ + value: { x: 1 }, + variationIndex: 0, + reason: { kind: 'OFF' }, + }); + + let renderCount = 0; + + function FlagConsumer() { + useFlagDetail('my-flag', {}); + renderCount += 1; + return null; + } + + const Wrapper = makeWrapper(mockClient); + render( + + + , + ); + + await act(async () => {}); + + expect(renderCount).toBeLessThanOrEqual(3); +}); + +it('calls variation detail again when context changes after identify', () => { + const mockClient = makeMockClient(); + (mockClient.boolVariationDetail as jest.Mock).mockReturnValue({ + value: true, + variationIndex: 0, + reason: { kind: 'OFF' }, + }); + + const { Wrapper: StatefulWrapper, setterRef } = makeStatefulWrapper(mockClient); + + function FlagConsumer() { + useFlagDetail('my-flag', false); + return null; + } + + render( + + + , + ); + + const callsBefore = (mockClient.boolVariationDetail as jest.Mock).mock.calls.length; + + act(() => { + setterRef.current!({ + client: mockClient, + context: { kind: 'user', key: 'new-user' }, + initializedState: 'complete', + }); + }); + + expect((mockClient.boolVariationDetail as jest.Mock).mock.calls.length).toBeGreaterThan( + callsBefore, + ); +}); + +it('logs a deprecation warning on mount via client.logger.warn', async () => { + const mockClient = makeMockClient(); + const Wrapper = makeWrapper(mockClient); + + function FlagConsumer() { + useFlagDetail('my-flag', false); + return null; + } + + await act(async () => { + render( + + + , + ); + }); + + expect(mockClient.logger.warn).toHaveBeenCalledWith( + expect.stringContaining('[LaunchDarkly] useFlagDetail is deprecated'), + ); +}); + +it('updates detail immediately when key changes without waiting for change event', () => { + const mockClient = makeMockClient(); + const detailA: LDEvaluationDetailTyped = { + value: false, + variationIndex: 0, + reason: { kind: 'OFF' }, + }; + const detailB: LDEvaluationDetailTyped = { + value: true, + variationIndex: 1, + reason: { kind: 'FALLTHROUGH' }, + }; + (mockClient.boolVariationDetail as jest.Mock).mockImplementation((key: string, _def: boolean) => { + if (key === 'flag-a') return detailA; + if (key === 'flag-b') return detailB; + return detailA; + }); + + const captured: LDEvaluationDetailTyped[] = []; + + function FlagConsumer({ flagKey }: { flagKey: string }) { + const d = useFlagDetail(flagKey, false); + captured.push(d); + return null; + } + + const Wrapper = makeWrapper(mockClient); + const { rerender } = render( + + + , + ); + + expect(captured[captured.length - 1]).toEqual(detailA); + + rerender( + + + , + ); + + expect(captured[captured.length - 1]).toEqual(detailB); + expect(mockClient.boolVariationDetail).toHaveBeenCalledWith('flag-b', false); +}); diff --git a/packages/sdk/react/__tests__/client/hooks/useFlags.test.tsx b/packages/sdk/react/__tests__/client/hooks/useFlags.test.tsx new file mode 100644 index 000000000..c05c8567d --- /dev/null +++ b/packages/sdk/react/__tests__/client/hooks/useFlags.test.tsx @@ -0,0 +1,184 @@ +/** + * @jest-environment jsdom + */ +import { act, render } from '@testing-library/react'; +import React from 'react'; + +import { useFlags } from '../../../src/client/deprecated-hooks/useFlags'; +import { makeMockClient } from '../mockClient'; +import { makeWrapper } from './renderHelpers'; + +function FlagsConsumer({ onFlags }: { onFlags: (flags: Record) => void }) { + const flags = useFlags(); + onFlags(flags); + return {JSON.stringify(flags)}; +} + +it('returns initial flag values from client.allFlags()', () => { + const mockClient = makeMockClient(); + (mockClient.allFlags as jest.Mock).mockReturnValue({ 'my-flag': true }); + + const captured: Record[] = []; + + const Wrapper = makeWrapper(mockClient); + render( + + captured.push(f)} /> + , + ); + + expect(captured[0]).toEqual({ 'my-flag': true }); +}); + +it('subscribes to change event on mount and unsubscribes on unmount', () => { + const mockClient = makeMockClient(); + + const Wrapper = makeWrapper(mockClient); + const { unmount } = render( + + {}} /> + , + ); + + expect(mockClient.on).toHaveBeenCalledWith('change', expect.any(Function)); + + const onCall = (mockClient.on as jest.Mock).mock.calls.find( + ([event]: [string]) => event === 'change', + ); + const handler = onCall?.[1]; + + unmount(); + + expect(mockClient.off).toHaveBeenCalledWith('change', handler); +}); + +it('re-renders with new flags when change event fires', async () => { + const mockClient = makeMockClient(); + (mockClient.allFlags as jest.Mock).mockReturnValue({ 'flag-a': false }); + + const captured: Record[] = []; + + const Wrapper = makeWrapper(mockClient); + render( + + captured.push(f)} /> + , + ); + + expect(captured[captured.length - 1]).toEqual({ 'flag-a': false }); + + (mockClient.allFlags as jest.Mock).mockReturnValue({ 'flag-a': true }); + + await act(async () => { + mockClient.emitChange(); + }); + + expect(captured[captured.length - 1]).toEqual({ 'flag-a': true }); +}); + +it('logs a deprecation warning on mount via client.logger.warn', async () => { + const mockClient = makeMockClient(); + const Wrapper = makeWrapper(mockClient); + + function FlagConsumer() { + useFlags(); + return null; + } + + await act(async () => { + render( + + + , + ); + }); + + expect(mockClient.logger.warn).toHaveBeenCalledWith( + expect.stringContaining('[LaunchDarkly] useFlags is deprecated'), + ); +}); + +it('calls client.variation when reading a flag value from the returned object', () => { + const mockClient = makeMockClient(); + (mockClient.allFlags as jest.Mock).mockReturnValue({ 'my-flag': true }); + + let capturedFlags: Record = {}; + + function FlagReader() { + capturedFlags = useFlags(); + return null; + } + + const Wrapper = makeWrapper(mockClient); + render( + + + , + ); + + // Reading a flag through the proxy should call variation, not just return the allFlags value + const value = capturedFlags['my-flag']; + expect(mockClient.variation).toHaveBeenCalledWith('my-flag', true); + expect(value).toBe(true); +}); + +it('calls client.variation only once per flag key when the same key is read multiple times', () => { + const mockClient = makeMockClient(); + (mockClient.allFlags as jest.Mock).mockReturnValue({ 'my-flag': true }); + + let capturedFlags: Record = {}; + + function FlagReader() { + capturedFlags = useFlags(); + return null; + } + + const Wrapper = makeWrapper(mockClient); + render( + + + , + ); + + // eslint-disable-next-line @typescript-eslint/no-unused-expressions + capturedFlags['my-flag']; + + // eslint-disable-next-line @typescript-eslint/no-unused-expressions + capturedFlags['my-flag']; + + const calls = (mockClient.variation as jest.Mock).mock.calls.filter( + ([key]: [string]) => key === 'my-flag', + ); + expect(calls).toHaveLength(1); +}); + +it('does not re-render when a different key changes', async () => { + const mockClient = makeMockClient(); + (mockClient.allFlags as jest.Mock).mockReturnValue({ 'flag-a': false, 'flag-b': false }); + + let renderCount = 0; + + function CountingConsumer() { + const flags = useFlags(); + renderCount += 1; + return {JSON.stringify(flags)}; + } + + const Wrapper = makeWrapper(mockClient); + render( + + + , + ); + + const initialRenders = renderCount; + + // useFlags subscribes to 'change' (all flags), so any flag change triggers re-render. + // This test verifies that flag-specific change (change:flag-b) does NOT trigger useFlags. + await act(async () => { + mockClient.emitFlagChange('flag-b'); + }); + + // 'change:flag-b' should not trigger the 'change' handler used by useFlags + expect(renderCount).toBe(initialRenders); +}); diff --git a/packages/sdk/react/src/client/deprecated-hooks/useFlag.ts b/packages/sdk/react/src/client/deprecated-hooks/useFlag.ts new file mode 100644 index 000000000..102a7e8ec --- /dev/null +++ b/packages/sdk/react/src/client/deprecated-hooks/useFlag.ts @@ -0,0 +1,50 @@ +'use client'; + +import { useContext, useEffect } from 'react'; + +import useVariationCore from '../hooks/useVariationCore'; +import type { LDReactClient, LDReactClientContextValue } from '../LDClient'; +import { LDReactContext } from '../provider/LDReactContext'; + +/** + * The set of types that can be used as a feature flag value with `useFlag` and `useFlagDetail`. + */ +export type LDFlagType = boolean | string | number | object; + +function getVariation( + client: LDReactClient, + key: string, + defaultValue: T, +): T { + if (typeof defaultValue === 'boolean') return client.boolVariation(key, defaultValue) as T; + if (typeof defaultValue === 'string') return client.stringVariation(key, defaultValue) as T; + if (typeof defaultValue === 'number') return client.numberVariation(key, defaultValue) as T; + return client.jsonVariation(key, defaultValue) as T; +} + +/** + * Returns the value of a single feature flag, re-rendering only when that specific flag changes. + * + * @param key The feature flag key. + * @param defaultValue The value to return if the flag is not available. + * @param reactContext Optional React context to read from. Defaults to the global `LDReactContext`. + * @returns The typed flag value, or `defaultValue` if the flag is unavailable. + * + * @deprecated Use `useLDClient` with the client's variation methods directly. This hook will be + * removed in a future major version. + */ +export function useFlag( + key: string, + defaultValue: T, + reactContext?: React.Context, +): T { + const { client } = useContext(reactContext ?? LDReactContext); + + useEffect(() => { + client.logger.warn( + '[LaunchDarkly] useFlag is deprecated and will be removed in a future major version.', + ); + }, []); + + return useVariationCore(key, defaultValue, getVariation, reactContext); +} diff --git a/packages/sdk/react/src/client/deprecated-hooks/useFlagDetail.ts b/packages/sdk/react/src/client/deprecated-hooks/useFlagDetail.ts new file mode 100644 index 000000000..0fa2d5eab --- /dev/null +++ b/packages/sdk/react/src/client/deprecated-hooks/useFlagDetail.ts @@ -0,0 +1,57 @@ +'use client'; + +import { useContext, useEffect } from 'react'; + +import type { LDEvaluationDetailTyped } from '@launchdarkly/js-client-sdk'; + +import useVariationCore from '../hooks/useVariationCore'; +import type { LDReactClient, LDReactClientContextValue } from '../LDClient'; +import { LDReactContext } from '../provider/LDReactContext'; +import type { LDFlagType } from './useFlag'; + +function getVariationDetail( + client: LDReactClient, + key: string, + defaultValue: T, +): LDEvaluationDetailTyped { + if (typeof defaultValue === 'boolean') + return client.boolVariationDetail(key, defaultValue) as LDEvaluationDetailTyped; + if (typeof defaultValue === 'string') + return client.stringVariationDetail(key, defaultValue) as LDEvaluationDetailTyped; + if (typeof defaultValue === 'number') + return client.numberVariationDetail(key, defaultValue) as LDEvaluationDetailTyped; + return client.jsonVariationDetail(key, defaultValue) as LDEvaluationDetailTyped; +} + +/** + * Returns the evaluation detail of a single feature flag, re-rendering only when that specific + * flag changes. + * + * @param key The feature flag key. + * @param defaultValue The value to return if the flag is not available. + * @param reactContext Optional React context to read from. Defaults to the global `LDReactContext`. + * @returns The typed evaluation detail, including `value`, `variationIndex`, and `reason`. + * + * @deprecated Use `useLDClient` with the client's variationDetail methods directly. This hook will + * be removed in a future major version. + */ +export function useFlagDetail( + key: string, + defaultValue: T, + reactContext?: React.Context, +): LDEvaluationDetailTyped { + const { client } = useContext(reactContext ?? LDReactContext); + + useEffect(() => { + client.logger.warn( + '[LaunchDarkly] useFlagDetail is deprecated and will be removed in a future major version.', + ); + }, []); + + return useVariationCore>( + key, + defaultValue, + getVariationDetail, + reactContext, + ); +} diff --git a/packages/sdk/react/src/client/deprecated-hooks/useFlags.ts b/packages/sdk/react/src/client/deprecated-hooks/useFlags.ts index 28b74d038..664616c6b 100644 --- a/packages/sdk/react/src/client/deprecated-hooks/useFlags.ts +++ b/packages/sdk/react/src/client/deprecated-hooks/useFlags.ts @@ -14,8 +14,6 @@ function toFlagsProxy(client: LDReactClient, flags: T): T { // There is still an potential issue here if this function is used to only evaluate a // small subset of flags. In this case, any flag updates will cause a reset of the cache. - // It is recommended to use the typed variation hooks (useBoolVariation, useStringVariation, - // useNumberVariation, useJsonVariation) for better performance when reading a subset of flags. const cache = new Map(); return new Proxy(flags, { @@ -75,7 +73,7 @@ export function useFlags( return () => client.off('change', handler); }, [client]); - // context is included so the proxy is recreated on every identity change, - // ensuring variation is re-called for the new LaunchDarkly context. + // Context is included so the proxy is recreated on every identity change, + // ensuring variations are re-called for the new LaunchDarkly context. return useMemo(() => toFlagsProxy(client, flags), [client, flags, context]) as T; } From 49f1b4af1e4f914c9004f5e3611cf5ae1b078064 Mon Sep 17 00:00:00 2001 From: Steven Zhang Date: Tue, 10 Mar 2026 12:29:39 -0500 Subject: [PATCH 2/7] chore: initial comments --- .../useFlag.test.tsx | 0 .../useFlagDetail.test.tsx | 0 .../__tests__/client/hooks/renderHelpers.tsx | 37 ---- .../__tests__/client/hooks/useFlags.test.tsx | 184 ------------------ .../src/client/deprecated-hooks/useFlag.ts | 2 + .../client/deprecated-hooks/useFlagDetail.ts | 2 + 6 files changed, 4 insertions(+), 221 deletions(-) rename packages/sdk/react/__tests__/client/{hooks => deprecated-hooks}/useFlag.test.tsx (100%) rename packages/sdk/react/__tests__/client/{hooks => deprecated-hooks}/useFlagDetail.test.tsx (100%) delete mode 100644 packages/sdk/react/__tests__/client/hooks/renderHelpers.tsx delete mode 100644 packages/sdk/react/__tests__/client/hooks/useFlags.test.tsx diff --git a/packages/sdk/react/__tests__/client/hooks/useFlag.test.tsx b/packages/sdk/react/__tests__/client/deprecated-hooks/useFlag.test.tsx similarity index 100% rename from packages/sdk/react/__tests__/client/hooks/useFlag.test.tsx rename to packages/sdk/react/__tests__/client/deprecated-hooks/useFlag.test.tsx diff --git a/packages/sdk/react/__tests__/client/hooks/useFlagDetail.test.tsx b/packages/sdk/react/__tests__/client/deprecated-hooks/useFlagDetail.test.tsx similarity index 100% rename from packages/sdk/react/__tests__/client/hooks/useFlagDetail.test.tsx rename to packages/sdk/react/__tests__/client/deprecated-hooks/useFlagDetail.test.tsx diff --git a/packages/sdk/react/__tests__/client/hooks/renderHelpers.tsx b/packages/sdk/react/__tests__/client/hooks/renderHelpers.tsx deleted file mode 100644 index b4ce8ccec..000000000 --- a/packages/sdk/react/__tests__/client/hooks/renderHelpers.tsx +++ /dev/null @@ -1,37 +0,0 @@ -import React from 'react'; - -import { LDReactClientContextValue } from '../../../src/client/LDClient'; -import { LDReactContext } from '../../../src/client/provider/LDReactContext'; -import { makeMockClient } from '../mockClient'; - -export function makeWrapper(mockClient: ReturnType) { - const contextValue: LDReactClientContextValue = { - client: mockClient, - initializedState: 'unknown', - }; - - return function Wrapper({ children }: { children: React.ReactNode }) { - return {children}; - }; -} - -/** - * Creates a wrapper whose context value can be updated after render. - * `setterRef.current` is set on first render and can be called inside `act()`. - */ -export function makeStatefulWrapper(mockClient: ReturnType) { - const setterRef = { - current: null as React.Dispatch> | null, - }; - - function Wrapper({ children }: { children: React.ReactNode }) { - const [ctxValue, setCtx] = React.useState({ - client: mockClient, - initializedState: 'complete', - }); - setterRef.current = setCtx; - return {children}; - } - - return { Wrapper, setterRef }; -} diff --git a/packages/sdk/react/__tests__/client/hooks/useFlags.test.tsx b/packages/sdk/react/__tests__/client/hooks/useFlags.test.tsx deleted file mode 100644 index c05c8567d..000000000 --- a/packages/sdk/react/__tests__/client/hooks/useFlags.test.tsx +++ /dev/null @@ -1,184 +0,0 @@ -/** - * @jest-environment jsdom - */ -import { act, render } from '@testing-library/react'; -import React from 'react'; - -import { useFlags } from '../../../src/client/deprecated-hooks/useFlags'; -import { makeMockClient } from '../mockClient'; -import { makeWrapper } from './renderHelpers'; - -function FlagsConsumer({ onFlags }: { onFlags: (flags: Record) => void }) { - const flags = useFlags(); - onFlags(flags); - return {JSON.stringify(flags)}; -} - -it('returns initial flag values from client.allFlags()', () => { - const mockClient = makeMockClient(); - (mockClient.allFlags as jest.Mock).mockReturnValue({ 'my-flag': true }); - - const captured: Record[] = []; - - const Wrapper = makeWrapper(mockClient); - render( - - captured.push(f)} /> - , - ); - - expect(captured[0]).toEqual({ 'my-flag': true }); -}); - -it('subscribes to change event on mount and unsubscribes on unmount', () => { - const mockClient = makeMockClient(); - - const Wrapper = makeWrapper(mockClient); - const { unmount } = render( - - {}} /> - , - ); - - expect(mockClient.on).toHaveBeenCalledWith('change', expect.any(Function)); - - const onCall = (mockClient.on as jest.Mock).mock.calls.find( - ([event]: [string]) => event === 'change', - ); - const handler = onCall?.[1]; - - unmount(); - - expect(mockClient.off).toHaveBeenCalledWith('change', handler); -}); - -it('re-renders with new flags when change event fires', async () => { - const mockClient = makeMockClient(); - (mockClient.allFlags as jest.Mock).mockReturnValue({ 'flag-a': false }); - - const captured: Record[] = []; - - const Wrapper = makeWrapper(mockClient); - render( - - captured.push(f)} /> - , - ); - - expect(captured[captured.length - 1]).toEqual({ 'flag-a': false }); - - (mockClient.allFlags as jest.Mock).mockReturnValue({ 'flag-a': true }); - - await act(async () => { - mockClient.emitChange(); - }); - - expect(captured[captured.length - 1]).toEqual({ 'flag-a': true }); -}); - -it('logs a deprecation warning on mount via client.logger.warn', async () => { - const mockClient = makeMockClient(); - const Wrapper = makeWrapper(mockClient); - - function FlagConsumer() { - useFlags(); - return null; - } - - await act(async () => { - render( - - - , - ); - }); - - expect(mockClient.logger.warn).toHaveBeenCalledWith( - expect.stringContaining('[LaunchDarkly] useFlags is deprecated'), - ); -}); - -it('calls client.variation when reading a flag value from the returned object', () => { - const mockClient = makeMockClient(); - (mockClient.allFlags as jest.Mock).mockReturnValue({ 'my-flag': true }); - - let capturedFlags: Record = {}; - - function FlagReader() { - capturedFlags = useFlags(); - return null; - } - - const Wrapper = makeWrapper(mockClient); - render( - - - , - ); - - // Reading a flag through the proxy should call variation, not just return the allFlags value - const value = capturedFlags['my-flag']; - expect(mockClient.variation).toHaveBeenCalledWith('my-flag', true); - expect(value).toBe(true); -}); - -it('calls client.variation only once per flag key when the same key is read multiple times', () => { - const mockClient = makeMockClient(); - (mockClient.allFlags as jest.Mock).mockReturnValue({ 'my-flag': true }); - - let capturedFlags: Record = {}; - - function FlagReader() { - capturedFlags = useFlags(); - return null; - } - - const Wrapper = makeWrapper(mockClient); - render( - - - , - ); - - // eslint-disable-next-line @typescript-eslint/no-unused-expressions - capturedFlags['my-flag']; - - // eslint-disable-next-line @typescript-eslint/no-unused-expressions - capturedFlags['my-flag']; - - const calls = (mockClient.variation as jest.Mock).mock.calls.filter( - ([key]: [string]) => key === 'my-flag', - ); - expect(calls).toHaveLength(1); -}); - -it('does not re-render when a different key changes', async () => { - const mockClient = makeMockClient(); - (mockClient.allFlags as jest.Mock).mockReturnValue({ 'flag-a': false, 'flag-b': false }); - - let renderCount = 0; - - function CountingConsumer() { - const flags = useFlags(); - renderCount += 1; - return {JSON.stringify(flags)}; - } - - const Wrapper = makeWrapper(mockClient); - render( - - - , - ); - - const initialRenders = renderCount; - - // useFlags subscribes to 'change' (all flags), so any flag change triggers re-render. - // This test verifies that flag-specific change (change:flag-b) does NOT trigger useFlags. - await act(async () => { - mockClient.emitFlagChange('flag-b'); - }); - - // 'change:flag-b' should not trigger the 'change' handler used by useFlags - expect(renderCount).toBe(initialRenders); -}); diff --git a/packages/sdk/react/src/client/deprecated-hooks/useFlag.ts b/packages/sdk/react/src/client/deprecated-hooks/useFlag.ts index 102a7e8ec..9ccb0a50a 100644 --- a/packages/sdk/react/src/client/deprecated-hooks/useFlag.ts +++ b/packages/sdk/react/src/client/deprecated-hooks/useFlag.ts @@ -38,6 +38,8 @@ export function useFlag( defaultValue: T, reactContext?: React.Context, ): T { + // useContext is called here (separate from the call inside useVariationCore) solely to access + // `client.logger` for the one-time deprecation warning below. const { client } = useContext(reactContext ?? LDReactContext); useEffect(() => { diff --git a/packages/sdk/react/src/client/deprecated-hooks/useFlagDetail.ts b/packages/sdk/react/src/client/deprecated-hooks/useFlagDetail.ts index 0fa2d5eab..268789aeb 100644 --- a/packages/sdk/react/src/client/deprecated-hooks/useFlagDetail.ts +++ b/packages/sdk/react/src/client/deprecated-hooks/useFlagDetail.ts @@ -40,6 +40,8 @@ export function useFlagDetail( defaultValue: T, reactContext?: React.Context, ): LDEvaluationDetailTyped { + // useContext is called here (separate from the call inside useVariationCore) solely to access + // `client.logger` for the one-time deprecation warning below. const { client } = useContext(reactContext ?? LDReactContext); useEffect(() => { From 4f9589f6f55c38a877132d81ed0c023fe6ee1bc4 Mon Sep 17 00:00:00 2001 From: Steven Zhang Date: Wed, 11 Mar 2026 11:11:25 -0500 Subject: [PATCH 3/7] chore: PR comments --- .../client/deprecated-hooks/useFlag.test.tsx | 311 ----------------- .../deprecated-hooks/useFlagDetail.test.tsx | 326 ------------------ .../src/client/deprecated-hooks/useFlag.ts | 52 --- .../client/deprecated-hooks/useFlagDetail.ts | 59 ---- 4 files changed, 748 deletions(-) delete mode 100644 packages/sdk/react/__tests__/client/deprecated-hooks/useFlag.test.tsx delete mode 100644 packages/sdk/react/__tests__/client/deprecated-hooks/useFlagDetail.test.tsx delete mode 100644 packages/sdk/react/src/client/deprecated-hooks/useFlag.ts delete mode 100644 packages/sdk/react/src/client/deprecated-hooks/useFlagDetail.ts diff --git a/packages/sdk/react/__tests__/client/deprecated-hooks/useFlag.test.tsx b/packages/sdk/react/__tests__/client/deprecated-hooks/useFlag.test.tsx deleted file mode 100644 index 5f76ea265..000000000 --- a/packages/sdk/react/__tests__/client/deprecated-hooks/useFlag.test.tsx +++ /dev/null @@ -1,311 +0,0 @@ -/** - * @jest-environment jsdom - */ -import { act, render } from '@testing-library/react'; -import React from 'react'; - -import { useFlag } from '../../../src/client/deprecated-hooks/useFlag'; -import { makeMockClient } from '../mockClient'; -import { makeStatefulWrapper, makeWrapper } from './renderHelpers'; - -it('dispatches to boolVariation for boolean defaultValue', () => { - const mockClient = makeMockClient(); - (mockClient.boolVariation as jest.Mock).mockReturnValue(true); - - const captured: boolean[] = []; - - function FlagConsumer() { - const value = useFlag('my-flag', false); - captured.push(value); - return null; - } - - const Wrapper = makeWrapper(mockClient); - render( - - - , - ); - - expect(mockClient.boolVariation).toHaveBeenCalledWith('my-flag', false); - expect(captured[0]).toBe(true); -}); - -it('dispatches to stringVariation for string defaultValue', () => { - const mockClient = makeMockClient(); - (mockClient.stringVariation as jest.Mock).mockReturnValue('hello'); - - const captured: string[] = []; - - function FlagConsumer() { - const value = useFlag('my-flag', 'default'); - captured.push(value); - return null; - } - - const Wrapper = makeWrapper(mockClient); - render( - - - , - ); - - expect(mockClient.stringVariation).toHaveBeenCalledWith('my-flag', 'default'); - expect(captured[0]).toBe('hello'); -}); - -it('dispatches to numberVariation for number defaultValue', () => { - const mockClient = makeMockClient(); - (mockClient.numberVariation as jest.Mock).mockReturnValue(42); - - const captured: number[] = []; - - function FlagConsumer() { - const value = useFlag('my-flag', 0); - captured.push(value); - return null; - } - - const Wrapper = makeWrapper(mockClient); - render( - - - , - ); - - expect(mockClient.numberVariation).toHaveBeenCalledWith('my-flag', 0); - expect(captured[0]).toBe(42); -}); - -it('dispatches to jsonVariation for object defaultValue', () => { - const mockClient = makeMockClient(); - const result = { enabled: true }; - (mockClient.jsonVariation as jest.Mock).mockReturnValue(result); - - const captured: object[] = []; - - function FlagConsumer() { - const value = useFlag('my-flag', {}); - captured.push(value); - return null; - } - - const Wrapper = makeWrapper(mockClient); - render( - - - , - ); - - expect(mockClient.jsonVariation).toHaveBeenCalledWith('my-flag', {}); - expect(captured[0]).toEqual(result); -}); - -it('subscribes to change: on mount and unsubscribes on unmount', () => { - const mockClient = makeMockClient(); - - function FlagConsumer() { - useFlag('my-flag', false); - return null; - } - - const Wrapper = makeWrapper(mockClient); - const { unmount } = render( - - - , - ); - - expect(mockClient.on).toHaveBeenCalledWith('change:my-flag', expect.any(Function)); - - const onCall = (mockClient.on as jest.Mock).mock.calls.find( - ([event]: [string]) => event === 'change:my-flag', - ); - const handler = onCall?.[1]; - - unmount(); - - expect(mockClient.off).toHaveBeenCalledWith('change:my-flag', handler); -}); - -it('re-renders with new value when change: fires', async () => { - const mockClient = makeMockClient(); - (mockClient.boolVariation as jest.Mock).mockReturnValue(false); - - const captured: boolean[] = []; - - function FlagConsumer() { - const value = useFlag('my-flag', false); - captured.push(value); - return null; - } - - const Wrapper = makeWrapper(mockClient); - render( - - - , - ); - - expect(captured[captured.length - 1]).toBe(false); - - (mockClient.boolVariation as jest.Mock).mockReturnValue(true); - - await act(async () => { - mockClient.emitFlagChange('my-flag'); - }); - - expect(captured[captured.length - 1]).toBe(true); -}); - -it('does not re-render when a different flag key changes', async () => { - const mockClient = makeMockClient(); - (mockClient.boolVariation as jest.Mock).mockReturnValue(false); - - let renderCount = 0; - - function FlagConsumer() { - useFlag('flag-a', false); - renderCount += 1; - return null; - } - - const Wrapper = makeWrapper(mockClient); - render( - - - , - ); - - const initialRenders = renderCount; - - await act(async () => { - mockClient.emitFlagChange('flag-b'); - }); - - expect(renderCount).toBe(initialRenders); -}); - -it('does not re-subscribe when parent re-renders with an inline object defaultValue', () => { - const mockClient = makeMockClient(); - (mockClient.jsonVariation as jest.Mock).mockReturnValue({ enabled: false }); - - let setParentState!: (n: number) => void; - - const Wrapper = makeWrapper(mockClient); - - function FlagConsumer({ n: _ }: { n: number }) { - useFlag('my-flag', {}); - return null; - } - - function Parent() { - const [n, setN] = React.useState(0); - setParentState = setN; - return ( - - - - ); - } - - render(); - - const onCallsBefore = (mockClient.on as jest.Mock).mock.calls.length; - - act(() => { - setParentState(1); - }); - act(() => { - setParentState(2); - }); - - expect((mockClient.on as jest.Mock).mock.calls.length).toBe(onCallsBefore); -}); - -it('calls variation again when context changes after identify', () => { - const mockClient = makeMockClient(); - (mockClient.boolVariation as jest.Mock).mockReturnValue(true); - - const { Wrapper: StatefulWrapper, setterRef } = makeStatefulWrapper(mockClient); - - function FlagConsumer() { - useFlag('my-flag', false); - return null; - } - - render( - - - , - ); - - const callsBefore = (mockClient.boolVariation as jest.Mock).mock.calls.length; - - act(() => { - setterRef.current!({ - client: mockClient, - context: { kind: 'user', key: 'new-user' }, - initializedState: 'complete', - }); - }); - - expect((mockClient.boolVariation as jest.Mock).mock.calls.length).toBeGreaterThan(callsBefore); -}); - -it('logs a deprecation warning on mount via client.logger.warn', async () => { - const mockClient = makeMockClient(); - const Wrapper = makeWrapper(mockClient); - - function FlagConsumer() { - useFlag('my-flag', false); - return null; - } - - await act(async () => { - render( - - - , - ); - }); - - expect(mockClient.logger.warn).toHaveBeenCalledWith( - expect.stringContaining('[LaunchDarkly] useFlag is deprecated'), - ); -}); - -it('updates value immediately when key changes without waiting for change event', () => { - const mockClient = makeMockClient(); - (mockClient.boolVariation as jest.Mock).mockImplementation((key: string, def: boolean) => { - if (key === 'flag-a') return false; - if (key === 'flag-b') return true; - return def; - }); - - const captured: boolean[] = []; - - function FlagConsumer({ flagKey }: { flagKey: string }) { - const value = useFlag(flagKey, false); - captured.push(value); - return null; - } - - const Wrapper = makeWrapper(mockClient); - const { rerender } = render( - - - , - ); - - expect(captured[captured.length - 1]).toBe(false); - - rerender( - - - , - ); - - expect(captured[captured.length - 1]).toBe(true); - expect(mockClient.boolVariation).toHaveBeenCalledWith('flag-b', false); -}); diff --git a/packages/sdk/react/__tests__/client/deprecated-hooks/useFlagDetail.test.tsx b/packages/sdk/react/__tests__/client/deprecated-hooks/useFlagDetail.test.tsx deleted file mode 100644 index 8a7974cdc..000000000 --- a/packages/sdk/react/__tests__/client/deprecated-hooks/useFlagDetail.test.tsx +++ /dev/null @@ -1,326 +0,0 @@ -/** - * @jest-environment jsdom - */ -import { act, render } from '@testing-library/react'; -import React from 'react'; - -import type { LDEvaluationDetailTyped } from '@launchdarkly/js-client-sdk'; - -import { useFlagDetail } from '../../../src/client/deprecated-hooks/useFlagDetail'; -import { makeMockClient } from '../mockClient'; -import { makeStatefulWrapper, makeWrapper } from './renderHelpers'; - -it('dispatches to boolVariationDetail for boolean defaultValue', () => { - const mockClient = makeMockClient(); - const detail: LDEvaluationDetailTyped = { - value: true, - variationIndex: 0, - reason: { kind: 'OFF' }, - }; - (mockClient.boolVariationDetail as jest.Mock).mockReturnValue(detail); - - const captured: LDEvaluationDetailTyped[] = []; - - function FlagConsumer() { - const d = useFlagDetail('my-flag', false); - captured.push(d); - return null; - } - - const Wrapper = makeWrapper(mockClient); - render( - - - , - ); - - expect(mockClient.boolVariationDetail).toHaveBeenCalledWith('my-flag', false); - expect(captured[0]).toEqual(detail); -}); - -it('dispatches to stringVariationDetail for string defaultValue', () => { - const mockClient = makeMockClient(); - const detail: LDEvaluationDetailTyped = { - value: 'on', - variationIndex: 1, - reason: { kind: 'RULE_MATCH', ruleIndex: 0, ruleId: 'r1' }, - }; - (mockClient.stringVariationDetail as jest.Mock).mockReturnValue(detail); - - const captured: LDEvaluationDetailTyped[] = []; - - function FlagConsumer() { - const d = useFlagDetail('my-flag', 'off'); - captured.push(d); - return null; - } - - const Wrapper = makeWrapper(mockClient); - render( - - - , - ); - - expect(mockClient.stringVariationDetail).toHaveBeenCalledWith('my-flag', 'off'); - expect(captured[0]).toEqual(detail); -}); - -it('dispatches to numberVariationDetail for number defaultValue', () => { - const mockClient = makeMockClient(); - const detail: LDEvaluationDetailTyped = { - value: 99, - variationIndex: 2, - reason: { kind: 'FALLTHROUGH' }, - }; - (mockClient.numberVariationDetail as jest.Mock).mockReturnValue(detail); - - const captured: LDEvaluationDetailTyped[] = []; - - function FlagConsumer() { - const d = useFlagDetail('my-flag', 0); - captured.push(d); - return null; - } - - const Wrapper = makeWrapper(mockClient); - render( - - - , - ); - - expect(mockClient.numberVariationDetail).toHaveBeenCalledWith('my-flag', 0); - expect(captured[0]).toEqual(detail); -}); - -it('dispatches to jsonVariationDetail for object defaultValue', () => { - const mockClient = makeMockClient(); - const detail: LDEvaluationDetailTyped = { - value: { x: 1 }, - variationIndex: 0, - reason: { kind: 'OFF' }, - }; - (mockClient.jsonVariationDetail as jest.Mock).mockReturnValue(detail); - - const captured: LDEvaluationDetailTyped[] = []; - - function FlagConsumer() { - const d = useFlagDetail('my-flag', {}); - captured.push(d); - return null; - } - - const Wrapper = makeWrapper(mockClient); - render( - - - , - ); - - expect(mockClient.jsonVariationDetail).toHaveBeenCalledWith('my-flag', {}); - expect(captured[0]).toEqual(detail); -}); - -it('subscribes to change: on mount and unsubscribes on unmount', () => { - const mockClient = makeMockClient(); - (mockClient.boolVariationDetail as jest.Mock).mockReturnValue({ - value: false, - variationIndex: 0, - reason: { kind: 'OFF' }, - }); - - function FlagConsumer() { - useFlagDetail('my-flag', false); - return null; - } - - const Wrapper = makeWrapper(mockClient); - const { unmount } = render( - - - , - ); - - expect(mockClient.on).toHaveBeenCalledWith('change:my-flag', expect.any(Function)); - - const onCall = (mockClient.on as jest.Mock).mock.calls.find( - ([event]: [string]) => event === 'change:my-flag', - ); - const handler = onCall?.[1]; - - unmount(); - - expect(mockClient.off).toHaveBeenCalledWith('change:my-flag', handler); -}); - -it('re-renders with updated detail when change: fires', async () => { - const mockClient = makeMockClient(); - const initialDetail: LDEvaluationDetailTyped = { - value: false, - variationIndex: 0, - reason: { kind: 'OFF' }, - }; - const updatedDetail: LDEvaluationDetailTyped = { - value: true, - variationIndex: 1, - reason: { kind: 'FALLTHROUGH' }, - }; - (mockClient.boolVariationDetail as jest.Mock).mockReturnValue(initialDetail); - - const captured: LDEvaluationDetailTyped[] = []; - - function FlagConsumer() { - const d = useFlagDetail('my-flag', false); - captured.push(d); - return null; - } - - const Wrapper = makeWrapper(mockClient); - render( - - - , - ); - - expect(captured[captured.length - 1]).toEqual(initialDetail); - - (mockClient.boolVariationDetail as jest.Mock).mockReturnValue(updatedDetail); - - await act(async () => { - mockClient.emitFlagChange('my-flag'); - }); - - expect(captured[captured.length - 1]).toEqual(updatedDetail); -}); - -it('does not loop infinitely when defaultValue is an inline object', async () => { - const mockClient = makeMockClient(); - (mockClient.jsonVariationDetail as jest.Mock).mockReturnValue({ - value: { x: 1 }, - variationIndex: 0, - reason: { kind: 'OFF' }, - }); - - let renderCount = 0; - - function FlagConsumer() { - useFlagDetail('my-flag', {}); - renderCount += 1; - return null; - } - - const Wrapper = makeWrapper(mockClient); - render( - - - , - ); - - await act(async () => {}); - - expect(renderCount).toBeLessThanOrEqual(3); -}); - -it('calls variation detail again when context changes after identify', () => { - const mockClient = makeMockClient(); - (mockClient.boolVariationDetail as jest.Mock).mockReturnValue({ - value: true, - variationIndex: 0, - reason: { kind: 'OFF' }, - }); - - const { Wrapper: StatefulWrapper, setterRef } = makeStatefulWrapper(mockClient); - - function FlagConsumer() { - useFlagDetail('my-flag', false); - return null; - } - - render( - - - , - ); - - const callsBefore = (mockClient.boolVariationDetail as jest.Mock).mock.calls.length; - - act(() => { - setterRef.current!({ - client: mockClient, - context: { kind: 'user', key: 'new-user' }, - initializedState: 'complete', - }); - }); - - expect((mockClient.boolVariationDetail as jest.Mock).mock.calls.length).toBeGreaterThan( - callsBefore, - ); -}); - -it('logs a deprecation warning on mount via client.logger.warn', async () => { - const mockClient = makeMockClient(); - const Wrapper = makeWrapper(mockClient); - - function FlagConsumer() { - useFlagDetail('my-flag', false); - return null; - } - - await act(async () => { - render( - - - , - ); - }); - - expect(mockClient.logger.warn).toHaveBeenCalledWith( - expect.stringContaining('[LaunchDarkly] useFlagDetail is deprecated'), - ); -}); - -it('updates detail immediately when key changes without waiting for change event', () => { - const mockClient = makeMockClient(); - const detailA: LDEvaluationDetailTyped = { - value: false, - variationIndex: 0, - reason: { kind: 'OFF' }, - }; - const detailB: LDEvaluationDetailTyped = { - value: true, - variationIndex: 1, - reason: { kind: 'FALLTHROUGH' }, - }; - (mockClient.boolVariationDetail as jest.Mock).mockImplementation((key: string, _def: boolean) => { - if (key === 'flag-a') return detailA; - if (key === 'flag-b') return detailB; - return detailA; - }); - - const captured: LDEvaluationDetailTyped[] = []; - - function FlagConsumer({ flagKey }: { flagKey: string }) { - const d = useFlagDetail(flagKey, false); - captured.push(d); - return null; - } - - const Wrapper = makeWrapper(mockClient); - const { rerender } = render( - - - , - ); - - expect(captured[captured.length - 1]).toEqual(detailA); - - rerender( - - - , - ); - - expect(captured[captured.length - 1]).toEqual(detailB); - expect(mockClient.boolVariationDetail).toHaveBeenCalledWith('flag-b', false); -}); diff --git a/packages/sdk/react/src/client/deprecated-hooks/useFlag.ts b/packages/sdk/react/src/client/deprecated-hooks/useFlag.ts deleted file mode 100644 index 9ccb0a50a..000000000 --- a/packages/sdk/react/src/client/deprecated-hooks/useFlag.ts +++ /dev/null @@ -1,52 +0,0 @@ -'use client'; - -import { useContext, useEffect } from 'react'; - -import useVariationCore from '../hooks/useVariationCore'; -import type { LDReactClient, LDReactClientContextValue } from '../LDClient'; -import { LDReactContext } from '../provider/LDReactContext'; - -/** - * The set of types that can be used as a feature flag value with `useFlag` and `useFlagDetail`. - */ -export type LDFlagType = boolean | string | number | object; - -function getVariation( - client: LDReactClient, - key: string, - defaultValue: T, -): T { - if (typeof defaultValue === 'boolean') return client.boolVariation(key, defaultValue) as T; - if (typeof defaultValue === 'string') return client.stringVariation(key, defaultValue) as T; - if (typeof defaultValue === 'number') return client.numberVariation(key, defaultValue) as T; - return client.jsonVariation(key, defaultValue) as T; -} - -/** - * Returns the value of a single feature flag, re-rendering only when that specific flag changes. - * - * @param key The feature flag key. - * @param defaultValue The value to return if the flag is not available. - * @param reactContext Optional React context to read from. Defaults to the global `LDReactContext`. - * @returns The typed flag value, or `defaultValue` if the flag is unavailable. - * - * @deprecated Use `useLDClient` with the client's variation methods directly. This hook will be - * removed in a future major version. - */ -export function useFlag( - key: string, - defaultValue: T, - reactContext?: React.Context, -): T { - // useContext is called here (separate from the call inside useVariationCore) solely to access - // `client.logger` for the one-time deprecation warning below. - const { client } = useContext(reactContext ?? LDReactContext); - - useEffect(() => { - client.logger.warn( - '[LaunchDarkly] useFlag is deprecated and will be removed in a future major version.', - ); - }, []); - - return useVariationCore(key, defaultValue, getVariation, reactContext); -} diff --git a/packages/sdk/react/src/client/deprecated-hooks/useFlagDetail.ts b/packages/sdk/react/src/client/deprecated-hooks/useFlagDetail.ts deleted file mode 100644 index 268789aeb..000000000 --- a/packages/sdk/react/src/client/deprecated-hooks/useFlagDetail.ts +++ /dev/null @@ -1,59 +0,0 @@ -'use client'; - -import { useContext, useEffect } from 'react'; - -import type { LDEvaluationDetailTyped } from '@launchdarkly/js-client-sdk'; - -import useVariationCore from '../hooks/useVariationCore'; -import type { LDReactClient, LDReactClientContextValue } from '../LDClient'; -import { LDReactContext } from '../provider/LDReactContext'; -import type { LDFlagType } from './useFlag'; - -function getVariationDetail( - client: LDReactClient, - key: string, - defaultValue: T, -): LDEvaluationDetailTyped { - if (typeof defaultValue === 'boolean') - return client.boolVariationDetail(key, defaultValue) as LDEvaluationDetailTyped; - if (typeof defaultValue === 'string') - return client.stringVariationDetail(key, defaultValue) as LDEvaluationDetailTyped; - if (typeof defaultValue === 'number') - return client.numberVariationDetail(key, defaultValue) as LDEvaluationDetailTyped; - return client.jsonVariationDetail(key, defaultValue) as LDEvaluationDetailTyped; -} - -/** - * Returns the evaluation detail of a single feature flag, re-rendering only when that specific - * flag changes. - * - * @param key The feature flag key. - * @param defaultValue The value to return if the flag is not available. - * @param reactContext Optional React context to read from. Defaults to the global `LDReactContext`. - * @returns The typed evaluation detail, including `value`, `variationIndex`, and `reason`. - * - * @deprecated Use `useLDClient` with the client's variationDetail methods directly. This hook will - * be removed in a future major version. - */ -export function useFlagDetail( - key: string, - defaultValue: T, - reactContext?: React.Context, -): LDEvaluationDetailTyped { - // useContext is called here (separate from the call inside useVariationCore) solely to access - // `client.logger` for the one-time deprecation warning below. - const { client } = useContext(reactContext ?? LDReactContext); - - useEffect(() => { - client.logger.warn( - '[LaunchDarkly] useFlagDetail is deprecated and will be removed in a future major version.', - ); - }, []); - - return useVariationCore>( - key, - defaultValue, - getVariationDetail, - reactContext, - ); -} From 6a6865108c706b1d34a9948c804cd63ec6bd294c Mon Sep 17 00:00:00 2001 From: Steven Zhang Date: Wed, 11 Mar 2026 16:43:53 -0500 Subject: [PATCH 4/7] chore: adding camelcase flag keys to useFlags hook --- .../deprecated-hooks/flagKeyUtils.test.ts | 38 ++ .../client/deprecated-hooks/renderHelpers.tsx | 6 +- .../client/deprecated-hooks/useFlags.test.tsx | 383 ++++++++++++++---- .../client/hooks/useVariation.test.tsx | 6 +- .../sdk/react/__tests__/client/mockClient.ts | 1 + packages/sdk/react/src/client/LDClient.ts | 10 + packages/sdk/react/src/client/LDOptions.ts | 2 + .../sdk/react/src/client/LDReactClient.tsx | 3 + .../client/deprecated-hooks/flagKeyUtils.ts | 25 ++ .../src/client/deprecated-hooks/useFlags.ts | 51 ++- 10 files changed, 433 insertions(+), 92 deletions(-) create mode 100644 packages/sdk/react/__tests__/client/deprecated-hooks/flagKeyUtils.test.ts create mode 100644 packages/sdk/react/src/client/deprecated-hooks/flagKeyUtils.ts diff --git a/packages/sdk/react/__tests__/client/deprecated-hooks/flagKeyUtils.test.ts b/packages/sdk/react/__tests__/client/deprecated-hooks/flagKeyUtils.test.ts new file mode 100644 index 000000000..8837a55ea --- /dev/null +++ b/packages/sdk/react/__tests__/client/deprecated-hooks/flagKeyUtils.test.ts @@ -0,0 +1,38 @@ +import { toCamelCase } from '../../../src/client/deprecated-hooks/flagKeyUtils'; + +it('converts kebab-case to camelCase', () => { + expect(toCamelCase('my-flag-key')).toBe('myFlagKey'); +}); + +it('converts snake_case to camelCase', () => { + expect(toCamelCase('my_flag_key')).toBe('myFlagKey'); +}); + +it('converts dot.separated to camelCase', () => { + expect(toCamelCase('my.flag.key')).toBe('myFlagKey'); +}); + +it('lowercases ALL_CAPS first word', () => { + expect(toCamelCase('MY_FLAG')).toBe('myFlag'); +}); + +it('preserves already-camelCase keys', () => { + expect(toCamelCase('myFlagKey')).toBe('myFlagKey'); +}); + +it('handles HTMLParser (ALLCAPS boundary)', () => { + expect(toCamelCase('HTMLParser')).toBe('htmlParser'); +}); + +it('handles a single word with no separators', () => { + expect(toCamelCase('flag')).toBe('flag'); +}); + +it('handles runs of multiple separators', () => { + expect(toCamelCase('my--flag')).toBe('myFlag'); + expect(toCamelCase('my_.flag')).toBe('myFlag'); +}); + +it('handles empty string', () => { + expect(toCamelCase('')).toBe(''); +}); diff --git a/packages/sdk/react/__tests__/client/deprecated-hooks/renderHelpers.tsx b/packages/sdk/react/__tests__/client/deprecated-hooks/renderHelpers.tsx index b4ce8ccec..aefd7a3ef 100644 --- a/packages/sdk/react/__tests__/client/deprecated-hooks/renderHelpers.tsx +++ b/packages/sdk/react/__tests__/client/deprecated-hooks/renderHelpers.tsx @@ -4,10 +4,14 @@ import { LDReactClientContextValue } from '../../../src/client/LDClient'; import { LDReactContext } from '../../../src/client/provider/LDReactContext'; import { makeMockClient } from '../mockClient'; -export function makeWrapper(mockClient: ReturnType) { +export function makeWrapper( + mockClient: ReturnType, + contextOverrides?: Partial, +) { const contextValue: LDReactClientContextValue = { client: mockClient, initializedState: 'unknown', + ...contextOverrides, }; return function Wrapper({ children }: { children: React.ReactNode }) { diff --git a/packages/sdk/react/__tests__/client/deprecated-hooks/useFlags.test.tsx b/packages/sdk/react/__tests__/client/deprecated-hooks/useFlags.test.tsx index 5a6a31753..a1149a805 100644 --- a/packages/sdk/react/__tests__/client/deprecated-hooks/useFlags.test.tsx +++ b/packages/sdk/react/__tests__/client/deprecated-hooks/useFlags.test.tsx @@ -5,38 +5,221 @@ import { act, render } from '@testing-library/react'; import React from 'react'; import { useFlags } from '../../../src/client/deprecated-hooks/useFlags'; +import { LDReactClientContextValue } from '../../../src/client/LDClient'; import { makeMockClient } from '../mockClient'; import { makeStatefulWrapper, makeWrapper } from './renderHelpers'; -function FlagsConsumer({ onFlags }: { onFlags: (flags: Record) => void }) { - const flags = useFlags(); - onFlags(flags); - return {JSON.stringify(flags)}; -} +// ─── camelCase default behavior ─────────────────────────────────────────────── -it('returns initial flag values from client.allFlags()', () => { +it('returns camelCased keys when useCamelCaseFlagKeys is true', () => { + const mockClient = makeMockClient({ + flagOverrides: { 'my-flag': true, 'another-flag': 'value' }, + }); + + const captured: Record[] = []; + + function FlagConsumer() { + const flags = useFlags(); + captured.push({ myFlag: flags.myFlag, anotherFlag: flags.anotherFlag }); + return null; + } + + const Wrapper = makeWrapper(mockClient); + render( + + + , + ); + + expect(captured[0].myFlag).toBe(true); + expect(captured[0].anotherFlag).toBe('value'); +}); + +it('returns original keys when useCamelCaseFlagKeys is false', () => { + const mockClient = makeMockClient({ flagOverrides: { 'my-flag': true } }); + (mockClient.shouldUseCamelCaseFlagKeys as jest.Mock).mockReturnValue(false); + + let capturedFlags: Record = {}; + + function FlagConsumer() { + const flags = useFlags(); + capturedFlags = { ...flags }; + return null; + } + + const Wrapper = makeWrapper(mockClient); + render( + + + , + ); + + expect(capturedFlags['my-flag']).toBe(true); + expect(capturedFlags.myFlag).toBeUndefined(); +}); + +it('defaults useCamelCaseFlagKeys to true when not passed', () => { + const mockClient = makeMockClient({ flagOverrides: { 'kebab-key': 42 } }); + + const captured: unknown[] = []; + + function FlagConsumer() { + const flags = useFlags(); + // @ts-ignore — dynamic access for test + captured.push(flags.kebabKey); + return null; + } + + const Wrapper = makeWrapper(mockClient); + render( + + + , + ); + + expect(captured[0]).toBe(42); +}); + +// ─── camelCase proxy behavior ───────────────────────────────────────────────── + +it('camelCased flags proxy supports `in` operator with camelCase key', () => { + const mockClient = makeMockClient({ flagOverrides: { 'my-flag': true } }); + + const captured: { hasCamel: boolean; hasOriginal: boolean }[] = []; + + function FlagConsumer() { + const flags = useFlags(); + captured.push({ + hasCamel: 'myFlag' in flags, + hasOriginal: 'my-flag' in flags, + }); + return null; + } + + const Wrapper = makeWrapper(mockClient); + render( + + + , + ); + + expect(captured[0].hasCamel).toBe(true); + expect(captured[0].hasOriginal).toBe(false); +}); + +it('Object.keys() on camelCased proxy returns camelCase keys', () => { + const mockClient = makeMockClient({ flagOverrides: { 'flag-one': 1, flag_two: 2 } }); + + let capturedKeys: string[] = []; + + function FlagConsumer() { + const flags = useFlags(); + capturedKeys = Object.keys(flags); + return null; + } + + const Wrapper = makeWrapper(mockClient); + render( + + + , + ); + + expect(capturedKeys.sort()).toEqual(['flagOne', 'flagTwo'].sort()); +}); + +it('spread of camelCased proxy produces camelCase key-value pairs', () => { + const mockClient = makeMockClient({ flagOverrides: { 'my-flag': true } }); + + let copy: Record = {}; + + function FlagConsumer() { + const flags = useFlags(); + copy = { ...flags }; + return null; + } + + const Wrapper = makeWrapper(mockClient); + render( + + + , + ); + + expect(copy.myFlag).toBe(true); + expect(copy['my-flag']).toBeUndefined(); +}); + +// ─── Variation recording ────────────────────────────────────────────────────── + +it('calls client.variation when reading a flag value from the returned object', () => { const mockClient = makeMockClient(); (mockClient.allFlags as jest.Mock).mockReturnValue({ 'my-flag': true }); - const captured: Record[] = []; + let capturedFlags: Record = {}; + + function FlagReader() { + capturedFlags = useFlags(); + return null; + } const Wrapper = makeWrapper(mockClient); render( - captured.push(f)} /> + , ); - expect(captured[0]).toEqual({ 'my-flag': true }); + // Reading a flag through the proxy should call variation, not just return the allFlags value + const value = capturedFlags.myFlag; + expect(mockClient.variation).toHaveBeenCalledWith('my-flag', true); + expect(value).toBe(true); +}); + +it('calls client.variation only once per flag key when the same key is read multiple times', () => { + const mockClient = makeMockClient(); + (mockClient.allFlags as jest.Mock).mockReturnValue({ 'my-flag': true }); + + let capturedFlags: Record = {}; + + function FlagReader() { + capturedFlags = useFlags(); + return null; + } + + const Wrapper = makeWrapper(mockClient); + render( + + + , + ); + + // eslint-disable-next-line @typescript-eslint/no-unused-expressions + capturedFlags.myFlag; + + // eslint-disable-next-line @typescript-eslint/no-unused-expressions + capturedFlags.myFlag; + + const calls = (mockClient.variation as jest.Mock).mock.calls.filter( + ([key]: [string]) => key === 'my-flag', + ); + expect(calls).toHaveLength(1); }); +// ─── Change event subscription ──────────────────────────────────────────────── + it('subscribes to change event on mount and unsubscribes on unmount', () => { const mockClient = makeMockClient(); + function FlagConsumer() { + useFlags(); + return null; + } + const Wrapper = makeWrapper(mockClient); const { unmount } = render( - {}} /> + , ); @@ -58,14 +241,20 @@ it('re-renders with new flags when change event fires', async () => { const captured: Record[] = []; + function FlagsConsumer() { + const flags = useFlags(); + captured.push(flags); + return null; + } + const Wrapper = makeWrapper(mockClient); render( - captured.push(f)} /> + , ); - expect(captured[captured.length - 1]).toEqual({ 'flag-a': false }); + expect(captured[captured.length - 1]).toEqual({ flagA: false }); (mockClient.allFlags as jest.Mock).mockReturnValue({ 'flag-a': true }); @@ -73,31 +262,70 @@ it('re-renders with new flags when change event fires', async () => { mockClient.emitChange(); }); - expect(captured[captured.length - 1]).toEqual({ 'flag-a': true }); + expect(captured[captured.length - 1]).toEqual({ flagA: true }); }); -it('logs a deprecation warning on mount via client.logger.warn', async () => { - const mockClient = makeMockClient(); - const Wrapper = makeWrapper(mockClient); +it('re-renders with updated camelCase value when emitChange fires with new flag data', async () => { + const mockClient = makeMockClient({ flagOverrides: { 'my-flag': false } }); + + const captured: unknown[] = []; function FlagConsumer() { - useFlags(); + const flags = useFlags(); + // @ts-ignore — dynamic access for test + captured.push(flags.myFlag); return null; } + const Wrapper = makeWrapper(mockClient); + render( + + + , + ); + + expect(captured[captured.length - 1]).toBe(false); + await act(async () => { - render( - - - , - ); + mockClient.emitChange({ 'my-flag': true }); }); - expect(mockClient.logger.warn).toHaveBeenCalledWith( - expect.stringContaining('[LaunchDarkly] useFlags is deprecated'), + expect(captured[captured.length - 1]).toBe(true); +}); + +it('does not re-render when a flag-specific change event fires', async () => { + const mockClient = makeMockClient(); + (mockClient.allFlags as jest.Mock).mockReturnValue({ 'flag-a': false, 'flag-b': false }); + + let renderCount = 0; + + function CountingConsumer() { + const flags = useFlags(); + renderCount += 1; + return {JSON.stringify(flags)}; + } + + const Wrapper = makeWrapper(mockClient); + render( + + + , ); + + const initialRenders = renderCount; + + // useFlags subscribes to 'change' (all flags), so any flag change triggers re-render. + // This test verifies that flag-specific change (change:flag-b) does NOT trigger useFlags. + await act(async () => { + mockClient.emitFlagChange('flag-b'); + }); + + // 'change:flag-b' should not trigger the 'change' handler used by useFlags + expect(renderCount).toBe(initialRenders); }); +// ─── Context change (re-identify) ───────────────────────────────────────────── + it('clears the variation cache when the context changes after identify', () => { const mockClient = makeMockClient(); (mockClient.allFlags as jest.Mock).mockReturnValue({ 'my-flag': true }); @@ -119,7 +347,7 @@ it('clears the variation cache when the context changes after identify', () => { // Read the flag to prime the variation cache // eslint-disable-next-line @typescript-eslint/no-unused-expressions - capturedFlags['my-flag']; + capturedFlags.myFlag; const callsBefore = (mockClient.variation as jest.Mock).mock.calls.length; // Simulate context change (e.g. after identify) @@ -133,91 +361,86 @@ it('clears the variation cache when the context changes after identify', () => { // Reading the same key again should call variation again (cache was cleared) // eslint-disable-next-line @typescript-eslint/no-unused-expressions - capturedFlags['my-flag']; + capturedFlags.myFlag; expect((mockClient.variation as jest.Mock).mock.calls.length).toBeGreaterThan(callsBefore); }); -it('calls client.variation when reading a flag value from the returned object', () => { - const mockClient = makeMockClient(); - (mockClient.allFlags as jest.Mock).mockReturnValue({ 'my-flag': true }); +// ─── Deprecation warning ────────────────────────────────────────────────────── - let capturedFlags: Record = {}; +it('logs a deprecation warning on mount via client.logger.warn', async () => { + const mockClient = makeMockClient(); + const Wrapper = makeWrapper(mockClient); - function FlagReader() { - capturedFlags = useFlags(); + function FlagConsumer() { + useFlags(); return null; } - const Wrapper = makeWrapper(mockClient); - render( - - - , - ); + await act(async () => { + render( + + + , + ); + }); - // Reading a flag through the proxy should call variation, not just return the allFlags value - const value = capturedFlags['my-flag']; - expect(mockClient.variation).toHaveBeenCalledWith('my-flag', true); - expect(value).toBe(true); + expect(mockClient.logger.warn).toHaveBeenCalledWith( + expect.stringContaining('[LaunchDarkly] useFlags is deprecated'), + ); }); -it('calls client.variation only once per flag key when the same key is read multiple times', () => { - const mockClient = makeMockClient(); - (mockClient.allFlags as jest.Mock).mockReturnValue({ 'my-flag': true }); +// ─── Custom React context ───────────────────────────────────────────────────── - let capturedFlags: Record = {}; +it('reads flags from a custom react context', () => { + const mockClient = makeMockClient({ flagOverrides: { 'custom-flag': 'hello' } }); + const CustomContext = React.createContext(null as any); - function FlagReader() { - capturedFlags = useFlags(); + const captured: unknown[] = []; + + function FlagConsumer() { + const flags = useFlags(CustomContext); + // @ts-ignore — dynamic access for test + captured.push(flags.customFlag); return null; } - const Wrapper = makeWrapper(mockClient); + const ctxValue: LDReactClientContextValue = { + client: mockClient, + initializedState: 'complete', + }; + render( - - - , + + + , ); - // eslint-disable-next-line @typescript-eslint/no-unused-expressions - capturedFlags['my-flag']; - - // eslint-disable-next-line @typescript-eslint/no-unused-expressions - capturedFlags['my-flag']; - - const calls = (mockClient.variation as jest.Mock).mock.calls.filter( - ([key]: [string]) => key === 'my-flag', - ); - expect(calls).toHaveLength(1); + expect(captured[0]).toBe('hello'); }); -it('does not re-render when a different key changes', async () => { - const mockClient = makeMockClient(); - (mockClient.allFlags as jest.Mock).mockReturnValue({ 'flag-a': false, 'flag-b': false }); +// ─── $ system key filtering ──────────────────────────────────────────────────── - let renderCount = 0; +it('filters out $ system keys from the returned flags object', () => { + const mockClient = makeMockClient({ + flagOverrides: { 'my-flag': true, '$system-key': 'internal' }, + }); - function CountingConsumer() { + let capturedKeys: string[] = []; + + function FlagConsumer() { const flags = useFlags(); - renderCount += 1; - return {JSON.stringify(flags)}; + capturedKeys = Object.keys(flags); + return null; } const Wrapper = makeWrapper(mockClient); render( - + , ); - const initialRenders = renderCount; - - // useFlags subscribes to 'change' (all flags), so any flag change triggers re-render. - // This test verifies that flag-specific change (change:flag-b) does NOT trigger useFlags. - await act(async () => { - mockClient.emitFlagChange('flag-b'); - }); - - // 'change:flag-b' should not trigger the 'change' handler used by useFlags - expect(renderCount).toBe(initialRenders); + expect(capturedKeys).toContain('myFlag'); + expect(capturedKeys).not.toContain('$systemKey'); + expect(capturedKeys).not.toContain('$system-key'); }); diff --git a/packages/sdk/react/__tests__/client/hooks/useVariation.test.tsx b/packages/sdk/react/__tests__/client/hooks/useVariation.test.tsx index 7f2b9d7fb..d30462423 100644 --- a/packages/sdk/react/__tests__/client/hooks/useVariation.test.tsx +++ b/packages/sdk/react/__tests__/client/hooks/useVariation.test.tsx @@ -14,10 +14,14 @@ import { LDReactClientContextValue } from '../../../src/client/LDClient'; import { LDReactContext } from '../../../src/client/provider/LDReactContext'; import { makeMockClient } from '../mockClient'; -function makeWrapper(mockClient: ReturnType) { +function makeWrapper( + mockClient: ReturnType, + contextOverrides?: Partial, +) { const contextValue: LDReactClientContextValue = { client: mockClient, initializedState: 'unknown', + ...contextOverrides, }; return function Wrapper({ children }: { children: React.ReactNode }) { diff --git a/packages/sdk/react/__tests__/client/mockClient.ts b/packages/sdk/react/__tests__/client/mockClient.ts index b4fee084c..09e42c3fb 100644 --- a/packages/sdk/react/__tests__/client/mockClient.ts +++ b/packages/sdk/react/__tests__/client/mockClient.ts @@ -88,6 +88,7 @@ export function makeMockClient(options: MockClientOptions = {}): MockClient { // @ts-ignore waitForInitialization: jest.fn(() => Promise.resolve({ status: 'complete' as const })), addHook: jest.fn(), + shouldUseCamelCaseFlagKeys: jest.fn(() => true), } as unknown as LDReactClient; return { diff --git a/packages/sdk/react/src/client/LDClient.ts b/packages/sdk/react/src/client/LDClient.ts index 552abd2c8..25d7e7aa1 100644 --- a/packages/sdk/react/src/client/LDClient.ts +++ b/packages/sdk/react/src/client/LDClient.ts @@ -52,6 +52,16 @@ export interface LDReactClient extends LDClient { onInitializationStatusChange( callback: (result: LDWaitForInitializationResult) => void, ): () => void; + + /** + * Returns whether flag keys should be converted to camelCase in `useFlags()` and resolved from camelCase + * in the individual variation hooks. Defaults to `true` when absent. + * + * @deprecated This method is deprecated and will be removed in a future major version. + * + * @returns {boolean} Whether flag keys should be converted to camelCase. + */ + shouldUseCamelCaseFlagKeys(): boolean; } /** diff --git a/packages/sdk/react/src/client/LDOptions.ts b/packages/sdk/react/src/client/LDOptions.ts index aa71e7096..d6da2e796 100644 --- a/packages/sdk/react/src/client/LDOptions.ts +++ b/packages/sdk/react/src/client/LDOptions.ts @@ -18,6 +18,8 @@ export interface LDReactClientOptions extends LDOptionsBase { * For more information, see the React SDK Reference Guide on * [flag keys](https://docs.launchdarkly.com/sdk/client-side/react/react-web#flag-keys). * + * @deprecated This option is deprecated and will be removed in a future major version. + * * @see https://docs.launchdarkly.com/sdk/client-side/react/react-web#flag-keys */ useCamelCaseFlagKeys?: boolean; diff --git a/packages/sdk/react/src/client/LDReactClient.tsx b/packages/sdk/react/src/client/LDReactClient.tsx index 54ddadfa0..17b8c266d 100644 --- a/packages/sdk/react/src/client/LDReactClient.tsx +++ b/packages/sdk/react/src/client/LDReactClient.tsx @@ -81,6 +81,7 @@ function createNoopReactClient(): LDReactClient { error: new Error('Server-side client cannot be used to wait for initialization'), }), addHook: () => {}, + shouldUseCamelCaseFlagKeys: () => true, }; } @@ -114,6 +115,7 @@ export function createClient( if (isServerSide()) { return createNoopReactClient(); } + const shouldUseCamelCaseFlagKeys = options?.useCamelCaseFlagKeys ?? true; const baseClient = createBaseClient(clientSideID, context, options); let initializationState: InitializedState = 'unknown'; @@ -165,5 +167,6 @@ export function createClient( initStatusSubscribers.delete(callback); }; }, + shouldUseCamelCaseFlagKeys: () => shouldUseCamelCaseFlagKeys, }; } diff --git a/packages/sdk/react/src/client/deprecated-hooks/flagKeyUtils.ts b/packages/sdk/react/src/client/deprecated-hooks/flagKeyUtils.ts new file mode 100644 index 000000000..27facfee3 --- /dev/null +++ b/packages/sdk/react/src/client/deprecated-hooks/flagKeyUtils.ts @@ -0,0 +1,25 @@ +/** + * Converts a flag key to camelCase, matching the behavior of the legacy + * launchdarkly-react-client-sdk. Handles kebab-case, snake_case, dot.separated, + * ALL_CAPS, and already-camelCased keys. + * + * Examples: + * 'my-flag-key' → 'myFlagKey' + * 'my_flag_key' → 'myFlagKey' + * 'my.flag.key' → 'myFlagKey' + * 'MY_FLAG' → 'myFlag' + * 'myFlagKey' → 'myFlagKey' + * 'HTMLParser' → 'htmlParser' + */ +export function toCamelCase(key: string): string { + return key + .replace(/([a-z])([A-Z])/g, '$1 $2') // camelCase boundary: myFlag → my Flag + .replace(/([A-Z]+)([A-Z][a-z])/g, '$1 $2') // ALLCAPS boundary: HTMLParser → HTML Parser + .split(/[-._\s]+/) + .filter(Boolean) + .map((word, i) => { + const lower = word.toLowerCase(); + return i === 0 ? lower : lower.charAt(0).toUpperCase() + lower.slice(1); + }) + .join(''); +} diff --git a/packages/sdk/react/src/client/deprecated-hooks/useFlags.ts b/packages/sdk/react/src/client/deprecated-hooks/useFlags.ts index 664616c6b..105e2a54a 100644 --- a/packages/sdk/react/src/client/deprecated-hooks/useFlags.ts +++ b/packages/sdk/react/src/client/deprecated-hooks/useFlags.ts @@ -6,8 +6,9 @@ import type { LDFlagSet } from '@launchdarkly/js-client-sdk'; import type { LDReactClient, LDReactClientContextValue } from '../LDClient'; import { LDReactContext } from '../provider/LDReactContext'; +import { toCamelCase } from './flagKeyUtils'; -function toFlagsProxy(client: LDReactClient, flags: T): T { +function toFlagsProxy(client: LDReactClient, rawFlags: T): T { // Cache the results of the variation calls to avoid redundant calls. // Note that this function is memoized, so when the context changes, the // cache is recreated. @@ -15,12 +16,32 @@ function toFlagsProxy(client: LDReactClient, flags: T): T { // There is still an potential issue here if this function is used to only evaluate a // small subset of flags. In this case, any flag updates will cause a reset of the cache. const cache = new Map(); + const useCamelCase = client.shouldUseCamelCaseFlagKeys(); - return new Proxy(flags, { + // Pre-build the display object, filtering out $ system keys. + // Mirrors getCamelizedKeysAndFlagMap in the old react-client-sdk. + const displayFlags: LDFlagSet = {}; + const flagKeyMap: Record = {}; // camelKey -> originalKey + + Object.keys(rawFlags as LDFlagSet) + .filter((rawKey) => rawKey.indexOf('$') !== 0) // exclude system keys (matches old SDK) + .forEach((rawKey) => { + if (useCamelCase) { + const camelKey = toCamelCase(rawKey); + displayFlags[camelKey] = (rawFlags as LDFlagSet)[rawKey]; + flagKeyMap[camelKey] = rawKey; + } else { + displayFlags[rawKey] = (rawFlags as LDFlagSet)[rawKey]; + } + }); + + // Only a get trap — matches old SDK's toFlagsProxy scope. + return new Proxy(displayFlags as T, { get(target, prop, receiver) { const currentValue = Reflect.get(target, prop, receiver); - // Pass through symbols and non-flag keys (e.g. Object prototype methods) + // Only intercept own flag keys; pass through symbols and prototype methods. + // Equivalent to old SDK: hasFlag(flagKeyMap, prop) || hasFlag(target, prop) if (typeof prop === 'symbol' || !Object.prototype.hasOwnProperty.call(target, prop)) { return currentValue; } @@ -29,13 +50,15 @@ function toFlagsProxy(client: LDReactClient, flags: T): T { return undefined; } - if (cache.has(prop)) { - return cache.get(prop); + if (cache.has(prop as string)) { + return cache.get(prop as string); } - // Trigger a variation call so LaunchDarkly records an evaluation event - const result = client.variation(prop as string, currentValue); - cache.set(prop, result); + const pristineKey = useCamelCase + ? (flagKeyMap[prop as string] ?? (prop as string)) + : (prop as string); + const result = client.variation(pristineKey, currentValue); + cache.set(prop as string, result); return result; }, }); @@ -46,8 +69,15 @@ function toFlagsProxy(client: LDReactClient, flags: T): T { * Flag values are accessed via a proxy that triggers a `variation` call on each read, ensuring * evaluation events are sent to LaunchDarkly for accurate usage metrics. * + * @remarks + * When `useCamelCaseFlagKeys` is `true`, flag keys are converted to camelCase. + * This means `"my-flag"` is accessible as `flags.myFlag`. Note that key collisions may occur + * if two flag keys differ only in separators (e.g. `'my-flag'` and `'my.flag'` both map to + * `'myFlag'` — last key wins), and Code References will not function correctly with camelCased keys. + * * @param reactContext Optional React context to read from. Defaults to the global `LDReactContext`. - * @returns All current flag values as `T`, wrapped in a proxy that records evaluations. + * @returns All current flag values, optionally with camelCased keys, wrapped in a proxy that + * records evaluations. * * @deprecated This hook is provided to ease migration from older versions of the React SDK. * For better performance, migrate to the typed variation hooks (`useBoolVariation`, @@ -68,10 +98,11 @@ export function useFlags( const [flags, setFlags] = useState(() => client.allFlags() as T); useEffect(() => { + setFlags(client.allFlags() as T); const handler = () => setFlags(client.allFlags() as T); client.on('change', handler); return () => client.off('change', handler); - }, [client]); + }, [client, context]); // Context is included so the proxy is recreated on every identity change, // ensuring variations are re-called for the new LaunchDarkly context. From 3adc167d3cfb12bf1b4a7d65ba3d98bc4090ceaf Mon Sep 17 00:00:00 2001 From: Steven Zhang Date: Fri, 13 Mar 2026 12:55:49 -0500 Subject: [PATCH 5/7] chore: bot comments --- .../client/deprecated-hooks/useFlags.test.tsx | 23 +++++++++++++++++++ .../src/client/deprecated-hooks/useFlags.ts | 8 +++++-- 2 files changed, 29 insertions(+), 2 deletions(-) diff --git a/packages/sdk/react/__tests__/client/deprecated-hooks/useFlags.test.tsx b/packages/sdk/react/__tests__/client/deprecated-hooks/useFlags.test.tsx index a1149a805..976c560e1 100644 --- a/packages/sdk/react/__tests__/client/deprecated-hooks/useFlags.test.tsx +++ b/packages/sdk/react/__tests__/client/deprecated-hooks/useFlags.test.tsx @@ -206,6 +206,29 @@ it('calls client.variation only once per flag key when the same key is read mult expect(calls).toHaveLength(1); }); +// ─── Extra render on mount ──────────────────────────────────────────────────── + +it('does not trigger an extra render on mount from setFlags', () => { + const mockClient = makeMockClient({ flagOverrides: { 'my-flag': true } }); + + let renderCount = 0; + + function FlagConsumer() { + useFlags(); + renderCount += 1; + return null; + } + + const Wrapper = makeWrapper(mockClient); + render( + + + , + ); + + expect(renderCount).toBe(1); +}); + // ─── Change event subscription ──────────────────────────────────────────────── it('subscribes to change event on mount and unsubscribes on unmount', () => { diff --git a/packages/sdk/react/src/client/deprecated-hooks/useFlags.ts b/packages/sdk/react/src/client/deprecated-hooks/useFlags.ts index 105e2a54a..da9fd20ae 100644 --- a/packages/sdk/react/src/client/deprecated-hooks/useFlags.ts +++ b/packages/sdk/react/src/client/deprecated-hooks/useFlags.ts @@ -1,6 +1,6 @@ 'use client'; -import { useContext, useEffect, useMemo, useState } from 'react'; +import { useContext, useEffect, useMemo, useRef, useState } from 'react'; import type { LDFlagSet } from '@launchdarkly/js-client-sdk'; @@ -96,9 +96,13 @@ export function useFlags( }, []); const [flags, setFlags] = useState(() => client.allFlags() as T); + const didMountRef = useRef(false); useEffect(() => { - setFlags(client.allFlags() as T); + if (didMountRef.current) { + setFlags(client.allFlags() as T); + } + didMountRef.current = true; const handler = () => setFlags(client.allFlags() as T); client.on('change', handler); return () => client.off('change', handler); From 48e9d777519c1a330981ff7f048344c993e0b28a Mon Sep 17 00:00:00 2001 From: Steven Zhang Date: Tue, 17 Mar 2026 10:25:31 -0500 Subject: [PATCH 6/7] test: adding more tests for camelcase --- .../deprecated-hooks/flagKeyUtils.test.ts | 85 +++++++++++++++++++ 1 file changed, 85 insertions(+) diff --git a/packages/sdk/react/__tests__/client/deprecated-hooks/flagKeyUtils.test.ts b/packages/sdk/react/__tests__/client/deprecated-hooks/flagKeyUtils.test.ts index 8837a55ea..0429f0227 100644 --- a/packages/sdk/react/__tests__/client/deprecated-hooks/flagKeyUtils.test.ts +++ b/packages/sdk/react/__tests__/client/deprecated-hooks/flagKeyUtils.test.ts @@ -36,3 +36,88 @@ it('handles runs of multiple separators', () => { it('handles empty string', () => { expect(toCamelCase('')).toBe(''); }); + +// ─── Already camelCase (idempotency) ────────────────────────────────────────── + +it('preserves multi-hump camelCase', () => { + expect(toCamelCase('myBigFlagKey')).toBe('myBigFlagKey'); +}); + +it('preserves short camelCase', () => { + expect(toCamelCase('xFlag')).toBe('xFlag'); +}); + +// ─── All caps ───────────────────────────────────────────────────────────────── + +it('lowercases a single all-caps word with no separators', () => { + expect(toCamelCase('ALLFLAG')).toBe('allflag'); +}); + +it('handles multi-word ALL_CAPS', () => { + expect(toCamelCase('ALL_CAPS_FLAG')).toBe('allCapsFlag'); +}); + +it('lowercases short all-caps abbreviations', () => { + expect(toCamelCase('URL')).toBe('url'); + expect(toCamelCase('API')).toBe('api'); +}); + +it('returns an all-lowercase word with no separators unchanged', () => { + expect(toCamelCase('flagkey')).toBe('flagkey'); +}); + +it('converts PascalCase to camelCase', () => { + expect(toCamelCase('MyFlagKey')).toBe('myFlagKey'); +}); + +// NOTE: This case should never happen as LaunchDarkly should handle invalid +// characters already. +it('preserves special characters that are not separators', () => { + expect(toCamelCase('my@flag')).toBe('my@flag'); + expect(toCamelCase('my#flag!')).toBe('my#flag!'); + expect(toCamelCase('flag$key')).toBe('flag$key'); + expect(toCamelCase('my+flag')).toBe('my+flag'); +}); + +it('keeps digits within a word token', () => { + expect(toCamelCase('flag2value')).toBe('flag2value'); +}); + +it('camelCases around digit-only segments separated by dashes', () => { + expect(toCamelCase('my-flag-123')).toBe('myFlag123'); + expect(toCamelCase('flag-2-value')).toBe('flag2Value'); +}); + +it('handles a leading digit segment', () => { + expect(toCamelCase('123-flag')).toBe('123Flag'); +}); + +it('preserves digits adjacent to camelCase boundaries', () => { + expect(toCamelCase('my2ndFlag')).toBe('my2ndFlag'); +}); + +// NOTE: This case should never happen as LaunchDarkly should handle invalid +// characters already. +it('ignores a leading separator', () => { + expect(toCamelCase('-my-flag')).toBe('myFlag'); +}); + +it('ignores a trailing separator', () => { + expect(toCamelCase('my-flag-')).toBe('myFlag'); +}); + +it('ignores leading and trailing separators together', () => { + expect(toCamelCase('.my.flag.')).toBe('myFlag'); +}); + +it('handles mixed separator types in one key', () => { + expect(toCamelCase('my-flag_key.value')).toBe('myFlagKeyValue'); +}); + +it('returns a single lowercase character unchanged', () => { + expect(toCamelCase('a')).toBe('a'); +}); + +it('lowercases a single uppercase character', () => { + expect(toCamelCase('A')).toBe('a'); +}); From 841ba582cfde658d519148009abba19600ae5adf Mon Sep 17 00:00:00 2001 From: Steven Zhang Date: Tue, 17 Mar 2026 10:57:13 -0500 Subject: [PATCH 7/7] chore: bot comment --- .../__tests__/client/deprecated-hooks/flagKeyUtils.test.ts | 6 ++++++ .../sdk/react/src/client/deprecated-hooks/flagKeyUtils.ts | 2 +- 2 files changed, 7 insertions(+), 1 deletion(-) diff --git a/packages/sdk/react/__tests__/client/deprecated-hooks/flagKeyUtils.test.ts b/packages/sdk/react/__tests__/client/deprecated-hooks/flagKeyUtils.test.ts index 0429f0227..3a6462251 100644 --- a/packages/sdk/react/__tests__/client/deprecated-hooks/flagKeyUtils.test.ts +++ b/packages/sdk/react/__tests__/client/deprecated-hooks/flagKeyUtils.test.ts @@ -96,6 +96,12 @@ it('preserves digits adjacent to camelCase boundaries', () => { expect(toCamelCase('my2ndFlag')).toBe('my2ndFlag'); }); +it('detects digit-to-uppercase boundary in camelCase keys', () => { + expect(toCamelCase('my2Flag')).toBe('my2Flag'); + expect(toCamelCase('flag2Value')).toBe('flag2Value'); + expect(toCamelCase('x2Y')).toBe('x2Y'); +}); + // NOTE: This case should never happen as LaunchDarkly should handle invalid // characters already. it('ignores a leading separator', () => { diff --git a/packages/sdk/react/src/client/deprecated-hooks/flagKeyUtils.ts b/packages/sdk/react/src/client/deprecated-hooks/flagKeyUtils.ts index 27facfee3..394951bfd 100644 --- a/packages/sdk/react/src/client/deprecated-hooks/flagKeyUtils.ts +++ b/packages/sdk/react/src/client/deprecated-hooks/flagKeyUtils.ts @@ -13,7 +13,7 @@ */ export function toCamelCase(key: string): string { return key - .replace(/([a-z])([A-Z])/g, '$1 $2') // camelCase boundary: myFlag → my Flag + .replace(/([a-z\d])([A-Z])/g, '$1 $2') // camelCase boundary: myFlag → my Flag, my2Flag → my2 Flag .replace(/([A-Z]+)([A-Z][a-z])/g, '$1 $2') // ALLCAPS boundary: HTMLParser → HTML Parser .split(/[-._\s]+/) .filter(Boolean)