Skip to content

Commit 9756e75

Browse files
authored
chore: adding deprecated flag hooks (#1166)
**Requirements** - [x] I have added test coverage for new or changed functionality - [x] I have followed the repository's [pull request submission guidelines](../blob/main/CONTRIBUTING.md#submitting-pull-requests) - [x] I have validated my changes against all supported platform versions **Related issues** sdk-1993 <!-- CURSOR_SUMMARY --> --- > [!NOTE] > **Medium Risk** > Introduces a new exported hook with Proxy-based evaluation behavior that can affect runtime performance and event emission semantics for consumers; changes are covered by targeted unit tests and are otherwise additive. > > **Overview** > Adds a **deprecated** `useFlags` hook to the React SDK, exposing all current flag values while wrapping them in a proxy that triggers `client.variation` on each flag read (with per-key caching) so evaluation events are recorded, and re-creating the proxy on context/identity changes. > > Exports the new hook from `client/index.ts`, adds Jest coverage for subscription/unsubscription, re-render behavior, deprecation warning logging, proxy caching semantics, and cache reset on context changes, and hardens `contract-tests/open-browser.mjs` by retrying `page.goto` to avoid startup race conditions. > > <sup>Written by [Cursor Bugbot](https://cursor.com/dashboard?tab=bugbot) for commit 732bd27. This will update automatically on new commits. Configure [here](https://cursor.com/dashboard?tab=bugbot).</sup> <!-- /CURSOR_SUMMARY --> <!-- devin-review-badge-begin --> --- <a href="https://app.devin.ai/review/launchdarkly/js-core/pull/1166" target="_blank"> <picture> <source media="(prefers-color-scheme: dark)" srcset="https://static.devin.ai/assets/gh-open-in-devin-review-dark.svg?v=1"> <img src="https://static.devin.ai/assets/gh-open-in-devin-review-light.svg?v=1" alt="Open with Devin"> </picture> </a> <!-- devin-review-badge-end -->
1 parent d96b46b commit 9756e75

6 files changed

Lines changed: 356 additions & 1 deletion

File tree

Lines changed: 37 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,37 @@
1+
import React from 'react';
2+
3+
import { LDReactClientContextValue } from '../../../src/client/LDClient';
4+
import { LDReactContext } from '../../../src/client/provider/LDReactContext';
5+
import { makeMockClient } from '../mockClient';
6+
7+
export function makeWrapper(mockClient: ReturnType<typeof makeMockClient>) {
8+
const contextValue: LDReactClientContextValue = {
9+
client: mockClient,
10+
initializedState: 'unknown',
11+
};
12+
13+
return function Wrapper({ children }: { children: React.ReactNode }) {
14+
return <LDReactContext.Provider value={contextValue}>{children}</LDReactContext.Provider>;
15+
};
16+
}
17+
18+
/**
19+
* Creates a wrapper whose context value can be updated after render.
20+
* `setterRef.current` is set on first render and can be called inside `act()`.
21+
*/
22+
export function makeStatefulWrapper(mockClient: ReturnType<typeof makeMockClient>) {
23+
const setterRef = {
24+
current: null as React.Dispatch<React.SetStateAction<LDReactClientContextValue>> | null,
25+
};
26+
27+
function Wrapper({ children }: { children: React.ReactNode }) {
28+
const [ctxValue, setCtx] = React.useState<LDReactClientContextValue>({
29+
client: mockClient,
30+
initializedState: 'complete',
31+
});
32+
setterRef.current = setCtx;
33+
return <LDReactContext.Provider value={ctxValue}>{children}</LDReactContext.Provider>;
34+
}
35+
36+
return { Wrapper, setterRef };
37+
}
Lines changed: 223 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,223 @@
1+
/**
2+
* @jest-environment jsdom
3+
*/
4+
import { act, render } from '@testing-library/react';
5+
import React from 'react';
6+
7+
import { useFlags } from '../../../src/client/deprecated-hooks/useFlags';
8+
import { makeMockClient } from '../mockClient';
9+
import { makeStatefulWrapper, makeWrapper } from './renderHelpers';
10+
11+
function FlagsConsumer({ onFlags }: { onFlags: (flags: Record<string, unknown>) => void }) {
12+
const flags = useFlags();
13+
onFlags(flags);
14+
return <span data-testid="output">{JSON.stringify(flags)}</span>;
15+
}
16+
17+
it('returns initial flag values from client.allFlags()', () => {
18+
const mockClient = makeMockClient();
19+
(mockClient.allFlags as jest.Mock).mockReturnValue({ 'my-flag': true });
20+
21+
const captured: Record<string, unknown>[] = [];
22+
23+
const Wrapper = makeWrapper(mockClient);
24+
render(
25+
<Wrapper>
26+
<FlagsConsumer onFlags={(f) => captured.push(f)} />
27+
</Wrapper>,
28+
);
29+
30+
expect(captured[0]).toEqual({ 'my-flag': true });
31+
});
32+
33+
it('subscribes to change event on mount and unsubscribes on unmount', () => {
34+
const mockClient = makeMockClient();
35+
36+
const Wrapper = makeWrapper(mockClient);
37+
const { unmount } = render(
38+
<Wrapper>
39+
<FlagsConsumer onFlags={() => {}} />
40+
</Wrapper>,
41+
);
42+
43+
expect(mockClient.on).toHaveBeenCalledWith('change', expect.any(Function));
44+
45+
const onCall = (mockClient.on as jest.Mock).mock.calls.find(
46+
([event]: [string]) => event === 'change',
47+
);
48+
const handler = onCall?.[1];
49+
50+
unmount();
51+
52+
expect(mockClient.off).toHaveBeenCalledWith('change', handler);
53+
});
54+
55+
it('re-renders with new flags when change event fires', async () => {
56+
const mockClient = makeMockClient();
57+
(mockClient.allFlags as jest.Mock).mockReturnValue({ 'flag-a': false });
58+
59+
const captured: Record<string, unknown>[] = [];
60+
61+
const Wrapper = makeWrapper(mockClient);
62+
render(
63+
<Wrapper>
64+
<FlagsConsumer onFlags={(f) => captured.push(f)} />
65+
</Wrapper>,
66+
);
67+
68+
expect(captured[captured.length - 1]).toEqual({ 'flag-a': false });
69+
70+
(mockClient.allFlags as jest.Mock).mockReturnValue({ 'flag-a': true });
71+
72+
await act(async () => {
73+
mockClient.emitChange();
74+
});
75+
76+
expect(captured[captured.length - 1]).toEqual({ 'flag-a': true });
77+
});
78+
79+
it('logs a deprecation warning on mount via client.logger.warn', async () => {
80+
const mockClient = makeMockClient();
81+
const Wrapper = makeWrapper(mockClient);
82+
83+
function FlagConsumer() {
84+
useFlags();
85+
return null;
86+
}
87+
88+
await act(async () => {
89+
render(
90+
<Wrapper>
91+
<FlagConsumer />
92+
</Wrapper>,
93+
);
94+
});
95+
96+
expect(mockClient.logger.warn).toHaveBeenCalledWith(
97+
expect.stringContaining('[LaunchDarkly] useFlags is deprecated'),
98+
);
99+
});
100+
101+
it('clears the variation cache when the context changes after identify', () => {
102+
const mockClient = makeMockClient();
103+
(mockClient.allFlags as jest.Mock).mockReturnValue({ 'my-flag': true });
104+
105+
const { Wrapper: StatefulWrapper, setterRef } = makeStatefulWrapper(mockClient);
106+
107+
let capturedFlags: Record<string, unknown> = {};
108+
109+
function FlagReader() {
110+
capturedFlags = useFlags();
111+
return null;
112+
}
113+
114+
render(
115+
<StatefulWrapper>
116+
<FlagReader />
117+
</StatefulWrapper>,
118+
);
119+
120+
// Read the flag to prime the variation cache
121+
// eslint-disable-next-line @typescript-eslint/no-unused-expressions
122+
capturedFlags['my-flag'];
123+
const callsBefore = (mockClient.variation as jest.Mock).mock.calls.length;
124+
125+
// Simulate context change (e.g. after identify)
126+
act(() => {
127+
setterRef.current!({
128+
client: mockClient,
129+
context: { kind: 'user', key: 'new-user' },
130+
initializedState: 'complete',
131+
});
132+
});
133+
134+
// Reading the same key again should call variation again (cache was cleared)
135+
// eslint-disable-next-line @typescript-eslint/no-unused-expressions
136+
capturedFlags['my-flag'];
137+
expect((mockClient.variation as jest.Mock).mock.calls.length).toBeGreaterThan(callsBefore);
138+
});
139+
140+
it('calls client.variation when reading a flag value from the returned object', () => {
141+
const mockClient = makeMockClient();
142+
(mockClient.allFlags as jest.Mock).mockReturnValue({ 'my-flag': true });
143+
144+
let capturedFlags: Record<string, unknown> = {};
145+
146+
function FlagReader() {
147+
capturedFlags = useFlags();
148+
return null;
149+
}
150+
151+
const Wrapper = makeWrapper(mockClient);
152+
render(
153+
<Wrapper>
154+
<FlagReader />
155+
</Wrapper>,
156+
);
157+
158+
// Reading a flag through the proxy should call variation, not just return the allFlags value
159+
const value = capturedFlags['my-flag'];
160+
expect(mockClient.variation).toHaveBeenCalledWith('my-flag', true);
161+
expect(value).toBe(true);
162+
});
163+
164+
it('calls client.variation only once per flag key when the same key is read multiple times', () => {
165+
const mockClient = makeMockClient();
166+
(mockClient.allFlags as jest.Mock).mockReturnValue({ 'my-flag': true });
167+
168+
let capturedFlags: Record<string, unknown> = {};
169+
170+
function FlagReader() {
171+
capturedFlags = useFlags();
172+
return null;
173+
}
174+
175+
const Wrapper = makeWrapper(mockClient);
176+
render(
177+
<Wrapper>
178+
<FlagReader />
179+
</Wrapper>,
180+
);
181+
182+
// eslint-disable-next-line @typescript-eslint/no-unused-expressions
183+
capturedFlags['my-flag'];
184+
185+
// eslint-disable-next-line @typescript-eslint/no-unused-expressions
186+
capturedFlags['my-flag'];
187+
188+
const calls = (mockClient.variation as jest.Mock).mock.calls.filter(
189+
([key]: [string]) => key === 'my-flag',
190+
);
191+
expect(calls).toHaveLength(1);
192+
});
193+
194+
it('does not re-render when a different key changes', async () => {
195+
const mockClient = makeMockClient();
196+
(mockClient.allFlags as jest.Mock).mockReturnValue({ 'flag-a': false, 'flag-b': false });
197+
198+
let renderCount = 0;
199+
200+
function CountingConsumer() {
201+
const flags = useFlags();
202+
renderCount += 1;
203+
return <span>{JSON.stringify(flags)}</span>;
204+
}
205+
206+
const Wrapper = makeWrapper(mockClient);
207+
render(
208+
<Wrapper>
209+
<CountingConsumer />
210+
</Wrapper>,
211+
);
212+
213+
const initialRenders = renderCount;
214+
215+
// useFlags subscribes to 'change' (all flags), so any flag change triggers re-render.
216+
// This test verifies that flag-specific change (change:flag-b) does NOT trigger useFlags.
217+
await act(async () => {
218+
mockClient.emitFlagChange('flag-b');
219+
});
220+
221+
// 'change:flag-b' should not trigger the 'change' handler used by useFlags
222+
expect(renderCount).toBe(initialRenders);
223+
});

packages/sdk/react/contract-tests/open-browser.mjs

Lines changed: 13 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -38,7 +38,19 @@ page.on('pageerror', (error) => {
3838
console.error(`[Browser Error] ${error.message}`);
3939
});
4040

41-
await page.goto(url);
41+
// Retry page.goto until the entity is ready (race-condition guard)
42+
const maxRetries = 15;
43+
const retryDelayMs = 2000;
44+
for (let attempt = 1; attempt <= maxRetries; attempt++) {
45+
try {
46+
await page.goto(url);
47+
break;
48+
} catch (err) {
49+
if (attempt === maxRetries) throw err;
50+
console.log(`[Browser] Connection to ${url} failed (attempt ${attempt}/${maxRetries}), retrying in ${retryDelayMs}ms...`);
51+
await new Promise((resolve) => setTimeout(resolve, retryDelayMs));
52+
}
53+
}
4254

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

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1 @@
1+
export { useFlags } from './useFlags';
Lines changed: 81 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,81 @@
1+
'use client';
2+
3+
import { useContext, useEffect, useMemo, useState } from 'react';
4+
5+
import type { LDFlagSet } from '@launchdarkly/js-client-sdk';
6+
7+
import type { LDReactClient, LDReactClientContextValue } from '../LDClient';
8+
import { LDReactContext } from '../provider/LDReactContext';
9+
10+
function toFlagsProxy<T extends LDFlagSet>(client: LDReactClient, flags: T): T {
11+
// Cache the results of the variation calls to avoid redundant calls.
12+
// Note that this function is memoized, so when the context changes, the
13+
// cache is recreated.
14+
15+
// There is still an potential issue here if this function is used to only evaluate a
16+
// small subset of flags. In this case, any flag updates will cause a reset of the cache.
17+
// It is recommended to use the typed variation hooks (useBoolVariation, useStringVariation,
18+
// useNumberVariation, useJsonVariation) for better performance when reading a subset of flags.
19+
const cache = new Map<string, unknown>();
20+
21+
return new Proxy(flags, {
22+
get(target, prop, receiver) {
23+
const currentValue = Reflect.get(target, prop, receiver);
24+
25+
// Pass through symbols and non-flag keys (e.g. Object prototype methods)
26+
if (typeof prop === 'symbol' || !Object.prototype.hasOwnProperty.call(target, prop)) {
27+
return currentValue;
28+
}
29+
30+
if (currentValue === undefined) {
31+
return undefined;
32+
}
33+
34+
if (cache.has(prop)) {
35+
return cache.get(prop);
36+
}
37+
38+
// Trigger a variation call so LaunchDarkly records an evaluation event
39+
const result = client.variation(prop as string, currentValue);
40+
cache.set(prop, result);
41+
return result;
42+
},
43+
});
44+
}
45+
46+
/**
47+
* Returns all feature flags for the current context. Re-renders whenever any flag value changes.
48+
* Flag values are accessed via a proxy that triggers a `variation` call on each read, ensuring
49+
* evaluation events are sent to LaunchDarkly for accurate usage metrics.
50+
*
51+
* @param reactContext Optional React context to read from. Defaults to the global `LDReactContext`.
52+
* @returns All current flag values as `T`, wrapped in a proxy that records evaluations.
53+
*
54+
* @deprecated This hook is provided to ease migration from older versions of the React SDK.
55+
* For better performance, migrate to the typed variation hooks (`useBoolVariation`,
56+
* `useStringVariation`, `useNumberVariation`, `useJsonVariation`) or use `useLDClient`
57+
* with the client's `allFlags` method directly. This hook will be removed in a future major version.
58+
*/
59+
export function useFlags<T extends LDFlagSet = LDFlagSet>(
60+
reactContext?: React.Context<LDReactClientContextValue>,
61+
): T {
62+
const { client, context } = useContext(reactContext ?? LDReactContext);
63+
64+
useEffect(() => {
65+
client.logger.warn(
66+
'[LaunchDarkly] useFlags is deprecated and will be removed in a future major version.',
67+
);
68+
}, []);
69+
70+
const [flags, setFlags] = useState<T>(() => client.allFlags() as T);
71+
72+
useEffect(() => {
73+
const handler = () => setFlags(client.allFlags() as T);
74+
client.on('change', handler);
75+
return () => client.off('change', handler);
76+
}, [client]);
77+
78+
// context is included so the proxy is recreated on every identity change,
79+
// ensuring variation is re-called for the new LaunchDarkly context.
80+
return useMemo(() => toFlagsProxy(client, flags), [client, flags, context]) as T;
81+
}

packages/sdk/react/src/client/index.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -6,4 +6,5 @@ export * from './provider/LDReactContext';
66
export { createLDReactProvider, createLDReactProviderWithClient } from './provider/LDReactProvider';
77
export { createClient } from './LDReactClient';
88

9+
export * from './deprecated-hooks';
910
export * from './hooks';

0 commit comments

Comments
 (0)