Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
@@ -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<typeof makeMockClient>) {
const contextValue: LDReactClientContextValue = {
client: mockClient,
initializedState: 'unknown',
};

return function Wrapper({ children }: { children: React.ReactNode }) {
return <LDReactContext.Provider value={contextValue}>{children}</LDReactContext.Provider>;
};
}

/**
* 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<typeof makeMockClient>) {
const setterRef = {
current: null as React.Dispatch<React.SetStateAction<LDReactClientContextValue>> | null,
};

function Wrapper({ children }: { children: React.ReactNode }) {
const [ctxValue, setCtx] = React.useState<LDReactClientContextValue>({
client: mockClient,
initializedState: 'complete',
});
setterRef.current = setCtx;
return <LDReactContext.Provider value={ctxValue}>{children}</LDReactContext.Provider>;
}

return { Wrapper, setterRef };
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,223 @@
/**
* @jest-environment jsdom
*/
import { act, render } from '@testing-library/react';
import React from 'react';

import { useFlags } from '../../../src/client/deprecated-hooks/useFlags';
Comment thread
joker23 marked this conversation as resolved.
import { makeMockClient } from '../mockClient';
import { makeStatefulWrapper, makeWrapper } from './renderHelpers';

function FlagsConsumer({ onFlags }: { onFlags: (flags: Record<string, unknown>) => void }) {
const flags = useFlags();
onFlags(flags);
return <span data-testid="output">{JSON.stringify(flags)}</span>;
}

it('returns initial flag values from client.allFlags()', () => {
const mockClient = makeMockClient();
(mockClient.allFlags as jest.Mock).mockReturnValue({ 'my-flag': true });

const captured: Record<string, unknown>[] = [];

const Wrapper = makeWrapper(mockClient);
render(
<Wrapper>
<FlagsConsumer onFlags={(f) => captured.push(f)} />
</Wrapper>,
);

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(
<Wrapper>
<FlagsConsumer onFlags={() => {}} />
</Wrapper>,
);

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<string, unknown>[] = [];

const Wrapper = makeWrapper(mockClient);
render(
<Wrapper>
<FlagsConsumer onFlags={(f) => captured.push(f)} />
</Wrapper>,
);

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(
<Wrapper>
<FlagConsumer />
</Wrapper>,
);
});

expect(mockClient.logger.warn).toHaveBeenCalledWith(
expect.stringContaining('[LaunchDarkly] useFlags is deprecated'),
);
});

it('clears the variation cache when the context changes after identify', () => {
const mockClient = makeMockClient();
(mockClient.allFlags as jest.Mock).mockReturnValue({ 'my-flag': true });

const { Wrapper: StatefulWrapper, setterRef } = makeStatefulWrapper(mockClient);

let capturedFlags: Record<string, unknown> = {};

function FlagReader() {
capturedFlags = useFlags();
return null;
}

render(
<StatefulWrapper>
<FlagReader />
</StatefulWrapper>,
);

// Read the flag to prime the variation cache
// eslint-disable-next-line @typescript-eslint/no-unused-expressions
capturedFlags['my-flag'];
const callsBefore = (mockClient.variation as jest.Mock).mock.calls.length;

// Simulate context change (e.g. after identify)
act(() => {
setterRef.current!({
client: mockClient,
context: { kind: 'user', key: 'new-user' },
initializedState: 'complete',
});
});

// Reading the same key again should call variation again (cache was cleared)
// eslint-disable-next-line @typescript-eslint/no-unused-expressions
capturedFlags['my-flag'];
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 });

let capturedFlags: Record<string, unknown> = {};

function FlagReader() {
capturedFlags = useFlags();
return null;
}

const Wrapper = makeWrapper(mockClient);
render(
<Wrapper>
<FlagReader />
</Wrapper>,
);

// 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<string, unknown> = {};

function FlagReader() {
capturedFlags = useFlags();
return null;
}

const Wrapper = makeWrapper(mockClient);
render(
<Wrapper>
<FlagReader />
</Wrapper>,
);

// 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 <span>{JSON.stringify(flags)}</span>;
}

const Wrapper = makeWrapper(mockClient);
render(
<Wrapper>
<CountingConsumer />
</Wrapper>,
);

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);
});
14 changes: 13 additions & 1 deletion packages/sdk/react/contract-tests/open-browser.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -38,7 +38,19 @@ page.on('pageerror', (error) => {
console.error(`[Browser Error] ${error.message}`);
});

await page.goto(url);
// Retry page.goto until the entity is ready (race-condition guard)
Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This makes me a smidge nervous.

Copy link
Copy Markdown
Contributor Author

@joker23 joker23 Mar 11, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This is done in lieu of waiting for the browser ready in the the workflow

const maxRetries = 15;
const retryDelayMs = 2000;
for (let attempt = 1; attempt <= maxRetries; attempt++) {
try {
await page.goto(url);
break;
} catch (err) {
if (attempt === maxRetries) throw err;
console.log(`[Browser] Connection to ${url} failed (attempt ${attempt}/${maxRetries}), retrying in ${retryDelayMs}ms...`);
await new Promise((resolve) => setTimeout(resolve, retryDelayMs));
}
}

console.log('Browser is open and running. Press Ctrl+C to close.');

Expand Down
1 change: 1 addition & 0 deletions packages/sdk/react/src/client/deprecated-hooks/index.ts
Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Implementation changes starts here

Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
export { useFlags } from './useFlags';
81 changes: 81 additions & 0 deletions packages/sdk/react/src/client/deprecated-hooks/useFlags.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,81 @@
'use client';

import { useContext, useEffect, useMemo, useState } from 'react';

import type { LDFlagSet } from '@launchdarkly/js-client-sdk';

import type { LDReactClient, LDReactClientContextValue } from '../LDClient';
import { LDReactContext } from '../provider/LDReactContext';

function toFlagsProxy<T extends LDFlagSet>(client: LDReactClient, flags: 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.

// 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<string, unknown>();
Comment thread
kinyoklion marked this conversation as resolved.

return new Proxy(flags, {
get(target, prop, receiver) {
const currentValue = Reflect.get(target, prop, receiver);

// Pass through symbols and non-flag keys (e.g. Object prototype methods)
if (typeof prop === 'symbol' || !Object.prototype.hasOwnProperty.call(target, prop)) {
return currentValue;
}

if (currentValue === undefined) {
return undefined;
}

if (cache.has(prop)) {
return cache.get(prop);
}

// Trigger a variation call so LaunchDarkly records an evaluation event
const result = client.variation(prop as string, currentValue);
cache.set(prop, result);
return result;
},
});
}

/**
* Returns all feature flags for the current context. Re-renders whenever any flag value changes.
* 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.
*
* @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.
*
* @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`,
* `useStringVariation`, `useNumberVariation`, `useJsonVariation`) or use `useLDClient`
* with the client's `allFlags` method directly. This hook will be removed in a future major version.
*/
export function useFlags<T extends LDFlagSet = LDFlagSet>(
Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

How does this work with Camelization?

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I will address Camelization in SDK-2014. Does this mean we will need to proxy client.allFlags to support this? Not sure if it makes much sense doing that anywhere else.

reactContext?: React.Context<LDReactClientContextValue>,
): T {
const { client, context } = useContext(reactContext ?? LDReactContext);

useEffect(() => {
client.logger.warn(
'[LaunchDarkly] useFlags is deprecated and will be removed in a future major version.',
);
}, []);

const [flags, setFlags] = useState<T>(() => client.allFlags() as T);

useEffect(() => {
const handler = () => setFlags(client.allFlags() as T);
client.on('change', handler);
return () => client.off('change', handler);
}, [client]);
Comment thread
joker23 marked this conversation as resolved.
Comment thread
cursor[bot] marked this conversation as resolved.

// context is included so the proxy is recreated on every identity change,
// ensuring variation is re-called for the new LaunchDarkly context.
return useMemo(() => toFlagsProxy(client, flags), [client, flags, context]) as T;
}
1 change: 1 addition & 0 deletions packages/sdk/react/src/client/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,4 +6,5 @@ export * from './provider/LDReactContext';
export { createLDReactProvider, createLDReactProviderWithClient } from './provider/LDReactProvider';
export { createClient } from './LDReactClient';

export * from './deprecated-hooks';
export * from './hooks';
Loading