Skip to content

Commit bde8e57

Browse files
refactor: simplify getting initializationState (#1202)
This PR will align our initialization status handling closer to the base browser sdk by: - combining `initializing` and `unknown` state which should get rid of one extra state dispatch on initialization - adding additional idempotent guards to the start function. Note that these are redundant protections. <!-- devin-review-badge-begin --> --- <a href="https://app.devin.ai/review/launchdarkly/js-core/pull/1202" 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 --> <!-- CURSOR_SUMMARY --> --- > [!NOTE] > **Medium Risk** > Changes the public initialization state semantics (removing `'unknown'` and defaulting to `'initializing'`), plus alters server-side/noop client error reporting, which may affect app gating logic and tests. > > **Overview** > **Unifies initialization state handling** by removing the initial `'unknown'` state in favor of starting at `'initializing'`, updating types so `InitializedState` is derived from a new `InitializationStatus` union. > > **Tightens `start()`/context notification behavior**: `start()` now has explicit idempotency guards and triggers `onContextChange` exactly once after the first successful `start()` resolution; shared context-subscriber notification logic is reused for `identify()`. > > **Updates server-side/noop client behavior and docs/tests**: the noop client now reports `getInitializationState()` as `'failed'` and returns a concrete error from `getInitializationError()`, with corresponding test and migration guide updates (including removing `'unknown'` from documented `useInitializationStatus` states). > > <sup>Written by [Cursor Bugbot](https://cursor.com/dashboard?tab=bugbot) for commit bac433e. This will update automatically on new commits. Configure [here](https://cursor.com/dashboard?tab=bugbot).</sup> <!-- /CURSOR_SUMMARY --> --------- Co-authored-by: devin-ai-integration[bot] <158243242+devin-ai-integration[bot]@users.noreply.github.com>
1 parent 86110d7 commit bde8e57

File tree

14 files changed

+84
-62
lines changed

14 files changed

+84
-62
lines changed

packages/sdk/react/__tests__/client/LDReactClient.test.ts

Lines changed: 38 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -76,37 +76,63 @@ jest.mock('@launchdarkly/js-client-sdk', () => {
7676
};
7777
});
7878

79-
it('returns getInitializationState() === "unknown" initially', () => {
79+
it('returns getInitializationState() === "initializing" initially', () => {
8080
const { mock } = makeMockBaseClient();
8181
(createBaseClient as jest.Mock).mockReturnValue(mock);
8282

8383
const client = createClient('test-id', { kind: 'user', key: 'u1' });
84-
expect(client.getInitializationState()).toBe('unknown');
84+
expect(client.getInitializationState()).toBe('initializing');
8585
});
8686

87-
it('returns "initializing" while start() is in-flight', async () => {
87+
it('sets initializedState to "complete" after start() resolves', async () => {
8888
const { mock, resolveStart } = makeMockBaseClient();
8989
(createBaseClient as jest.Mock).mockReturnValue(mock);
9090

9191
const client = createClient('test-id', { kind: 'user', key: 'u1' });
9292
const startPromise = client.start();
93-
expect(client.getInitializationState()).toBe('initializing');
94-
9593
resolveStart('complete');
9694
await startPromise;
95+
9796
expect(client.getInitializationState()).toBe('complete');
9897
});
9998

100-
it('sets initializedState to "complete" after start() resolves', async () => {
101-
const { mock, resolveStart } = makeMockBaseClient();
99+
it('start() fires onContextChange subscribers with the new context', async () => {
100+
const ctx: LDContextStrict = { kind: 'user', key: 'u1' };
101+
const { mock, resolveStart, setContext } = makeMockBaseClient();
102102
(createBaseClient as jest.Mock).mockReturnValue(mock);
103103

104104
const client = createClient('test-id', { kind: 'user', key: 'u1' });
105+
const received: LDContextStrict[] = [];
106+
client.onContextChange((c) => received.push(c));
107+
105108
const startPromise = client.start();
109+
setContext(ctx);
106110
resolveStart('complete');
107111
await startPromise;
108112

109-
expect(client.getInitializationState()).toBe('complete');
113+
expect(received).toHaveLength(1);
114+
expect(received[0]).toEqual(ctx);
115+
});
116+
117+
it('start() only notifies context subscribers once even if called multiple times', async () => {
118+
const ctx: LDContextStrict = { kind: 'user', key: 'u1' };
119+
const { mock, resolveStart, setContext } = makeMockBaseClient();
120+
(createBaseClient as jest.Mock).mockReturnValue(mock);
121+
122+
const client = createClient('test-id', { kind: 'user', key: 'u1' });
123+
const received: LDContextStrict[] = [];
124+
client.onContextChange((c) => received.push(c));
125+
126+
setContext(ctx);
127+
const startPromise1 = client.start();
128+
resolveStart('complete');
129+
await startPromise1;
130+
131+
// Second call should not notify again
132+
const startPromise2 = client.start();
133+
await startPromise2;
134+
135+
expect(received).toHaveLength(1);
110136
});
111137

112138
it('invokes onContextChange callback after identify() resolves with the new context', async () => {
@@ -296,13 +322,15 @@ it('getInitializationError() returns the error after a failed start()', async ()
296322
expect(client.getInitializationError()).toBe(failError);
297323
});
298324

299-
it('noop client getInitializationError() returns undefined', () => {
325+
it('noop client getInitializationError() returns an error', () => {
300326
const originalWindow = global.window;
301327
// @ts-ignore
302328
delete global.window;
303329

304330
const client = createClient('test-id', { kind: 'user', key: 'u1' });
305-
expect(client.getInitializationError()).toBeUndefined();
331+
expect(client.getInitializationError()).toEqual(
332+
new Error('Server-side client cannot be used to evaluate flags'),
333+
);
306334

307335
// @ts-ignore
308336
global.window = originalWindow;

packages/sdk/react/__tests__/client/deprecated-hooks/renderHelpers.tsx

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -10,7 +10,7 @@ export function makeWrapper(
1010
) {
1111
const contextValue: LDReactClientContextValue = {
1212
client: mockClient,
13-
initializedState: 'unknown',
13+
initializedState: 'initializing',
1414
...contextOverrides,
1515
};
1616

packages/sdk/react/__tests__/client/hooks/useInitializationStatus.test.tsx

Lines changed: 4 additions & 15 deletions
Original file line numberDiff line numberDiff line change
@@ -4,11 +4,12 @@
44
import { render } from '@testing-library/react';
55
import React from 'react';
66

7+
import { useInitializationStatus } from '../../../src/client/hooks/useInitializationStatus';
78
import {
89
InitializationStatus,
9-
useInitializationStatus,
10-
} from '../../../src/client/hooks/useInitializationStatus';
11-
import { InitializedState, LDReactClientContextValue } from '../../../src/client/LDClient';
10+
InitializedState,
11+
LDReactClientContextValue,
12+
} from '../../../src/client/LDClient';
1213
import { LDReactContext } from '../../../src/client/provider/LDReactContext';
1314
import { makeMockClient } from '../mockClient';
1415

@@ -23,18 +24,6 @@ function StatusConsumer({ onStatus }: { onStatus: (s: InitializationStatus) => v
2324
return null;
2425
}
2526

26-
it('returns { status: "unknown" } when initializedState is "unknown"', () => {
27-
const captured: InitializationStatus[] = [];
28-
29-
render(
30-
<LDReactContext.Provider value={makeContextValue('unknown')}>
31-
<StatusConsumer onStatus={(s) => captured.push(s)} />
32-
</LDReactContext.Provider>,
33-
);
34-
35-
expect(captured[0]).toEqual({ status: 'unknown' });
36-
});
37-
3827
it('returns { status: "initializing" } when initializedState is "initializing"', () => {
3928
const captured: InitializationStatus[] = [];
4029

packages/sdk/react/__tests__/client/hooks/useLDClient.test.tsx

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -19,7 +19,7 @@ it('returns the client from the nearest provider context', () => {
1919
const mockClient = makeMockClient();
2020
const contextValue: LDReactClientContextValue = {
2121
client: mockClient,
22-
initializedState: 'unknown',
22+
initializedState: 'initializing',
2323
};
2424

2525
let capturedClient: any;
@@ -43,7 +43,7 @@ it('returns the client from a custom react context', () => {
4343
const customContext = React.createContext<LDReactClientContextValue>(null as any);
4444
const contextValue: LDReactClientContextValue = {
4545
client: mockClient,
46-
initializedState: 'unknown',
46+
initializedState: 'initializing',
4747
};
4848

4949
let capturedClient: any;

packages/sdk/react/__tests__/client/hooks/useVariation.test.tsx

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -20,7 +20,7 @@ function makeWrapper(
2020
) {
2121
const contextValue: LDReactClientContextValue = {
2222
client: mockClient,
23-
initializedState: 'unknown',
23+
initializedState: 'initializing',
2424
...contextOverrides,
2525
};
2626

packages/sdk/react/__tests__/client/hooks/useVariationDetail.test.tsx

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -19,7 +19,7 @@ import { makeMockClient } from '../mockClient';
1919
function makeWrapper(mockClient: ReturnType<typeof makeMockClient>) {
2020
const contextValue: LDReactClientContextValue = {
2121
client: mockClient,
22-
initializedState: 'unknown',
22+
initializedState: 'initializing',
2323
};
2424

2525
return function Wrapper({ children }: { children: React.ReactNode }) {

packages/sdk/react/__tests__/client/mockClient.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -36,7 +36,7 @@ export function makeMockClient(options: MockClientOptions = {}): MockClient {
3636

3737
const initStatusSubscribers = new Set<(result: LDWaitForInitializationResult) => void>();
3838
const contextChangeSubscribers = new Set<(ctx: LDContextStrict) => void>();
39-
let initState: InitializedState = initialState ?? (preFailedError ? 'failed' : 'unknown');
39+
let initState: InitializedState = initialState ?? (preFailedError ? 'failed' : 'initializing');
4040
let initError: Error | undefined = preFailedError;
4141
let currentContext: LDContextStrict | undefined;
4242
const eventHandlers = new Map<string, Set<EventHandler>>();

packages/sdk/react/__tests__/client/provider/LDReactProvider.test.tsx

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -80,7 +80,7 @@ it('updates initializedState and context when onInitializationStatusChange fires
8080
</Provider>,
8181
);
8282

83-
expect(contextValues[contextValues.length - 1]?.initializedState).toBe('unknown');
83+
expect(contextValues[contextValues.length - 1]?.initializedState).toBe('initializing');
8484

8585
await act(async () => {
8686
client.fireInitStatusChange('complete');

packages/sdk/react/__tests__/client/provider/multipleEnvironments.test.tsx

Lines changed: 4 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -147,8 +147,8 @@ it('initialization state is tracked independently per context', async () => {
147147
</ProviderA>,
148148
);
149149

150-
expect(screen.getByTestId('status-a').textContent).toBe('unknown');
151-
expect(screen.getByTestId('status-b').textContent).toBe('unknown');
150+
expect(screen.getByTestId('status-a').textContent).toBe('initializing');
151+
expect(screen.getByTestId('status-b').textContent).toBe('initializing');
152152

153153
await act(async () => {
154154
clientA.fireInitStatusChange('complete');
@@ -157,8 +157,8 @@ it('initialization state is tracked independently per context', async () => {
157157
await waitFor(() => {
158158
expect(screen.getByTestId('status-a').textContent).toBe('complete');
159159
});
160-
// clientB still unknown
161-
expect(screen.getByTestId('status-b').textContent).toBe('unknown');
160+
// clientB still initializing
161+
expect(screen.getByTestId('status-b').textContent).toBe('initializing');
162162

163163
await act(async () => {
164164
clientB.fireInitStatusChange('complete');

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

Lines changed: 8 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -7,10 +7,15 @@ import {
77
} from '@launchdarkly/js-client-sdk';
88

99
/**
10-
* Initialization state of the client. This type should be consistent with
11-
* the `status` field of the `LDWaitForInitializationResult` type.
10+
* Represents the current initialization state of the LaunchDarkly client.
1211
*/
13-
export type InitializedState = LDWaitForInitializationResult['status'] | 'initializing' | 'unknown';
12+
export type InitializationStatus = LDWaitForInitializationResult | { status: 'initializing' };
13+
14+
/**
15+
* Initialization state of the client as a string union.
16+
* Derived from {@link InitializationStatus} for consistency.
17+
*/
18+
export type InitializedState = InitializationStatus['status'];
1419

1520
/**
1621
* The LaunchDarkly client interface for React.

0 commit comments

Comments
 (0)