Skip to content
Open
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
107 changes: 107 additions & 0 deletions packages/sdk/vue/__tests__/client/LDVueClient.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,107 @@
import { createClient as createBaseClient } from '@launchdarkly/js-client-sdk';

import { createClient } from '../../src/client/LDVueClient';

jest.mock('@launchdarkly/js-client-sdk', () => ({
createClient: jest.fn(),
}));

const createBaseClientMock = createBaseClient as jest.Mock;

type Result = { status: string; error?: Error };

const makeBaseClient = (overrides: Record<string, unknown> = {}) => ({
getContext: jest.fn(() => ({ kind: 'user', key: 'context-key' })),
start: jest.fn(() => Promise.resolve<Result>({ status: 'complete' })),
identify: jest.fn(() => Promise.resolve<Result>({ status: 'completed' })),
boolVariation: jest.fn(() => true),
on: jest.fn(),
off: jest.fn(),
close: jest.fn(),
...overrides,
});

beforeEach(() => {
createBaseClientMock.mockReset();
});

it('passes wrapper metadata to the base client', () => {
createBaseClientMock.mockReturnValue(makeBaseClient());

createClient('env-id', { kind: 'user', key: 'k' });

expect(createBaseClientMock).toHaveBeenCalledWith(
'env-id',
{ kind: 'user', key: 'k' },
expect.objectContaining({ wrapperName: 'vue-client-sdk', wrapperVersion: expect.any(String) }),
);
});

it('tracks initialization state through start()', async () => {
createBaseClientMock.mockReturnValue(makeBaseClient());
const client = createClient('env-id', { kind: 'user', key: 'k' });

expect(client.getInitializationState()).toBe('initializing');
expect(client.isReady()).toBe(false);

await client.start();

expect(client.getInitializationState()).toBe('complete');
expect(client.isReady()).toBe(true);
expect(client.getInitializationError()).toBeUndefined();
});

it('notifies init-status subscribers and replays the cached result to late subscribers', async () => {
createBaseClientMock.mockReturnValue(makeBaseClient());
const client = createClient('env-id', { kind: 'user', key: 'k' });

const early = jest.fn();
client.onInitializationStatusChange(early);

await client.start();

expect(early).toHaveBeenCalledWith({ status: 'complete' });

const late = jest.fn();
client.onInitializationStatusChange(late);
expect(late).toHaveBeenCalledWith({ status: 'complete' });
});

it('exposes the initialization error when start fails', async () => {
const error = new Error('boom');
createBaseClientMock.mockReturnValue(
makeBaseClient({ start: jest.fn(() => Promise.resolve({ status: 'failed', error })) }),
);
const client = createClient('env-id', { kind: 'user', key: 'k' });

await client.start();

expect(client.getInitializationState()).toBe('failed');
expect(client.getInitializationError()).toBe(error);
});

it('notifies context subscribers after a successful identify', async () => {
createBaseClientMock.mockReturnValue(makeBaseClient());
const client = createClient('env-id', { kind: 'user', key: 'k' });

const onContext = jest.fn();
client.onContextChange(onContext);

await client.identify({ kind: 'user', key: 'new-key' });

expect(onContext).toHaveBeenCalledWith({ kind: 'user', key: 'context-key' });
});

it('does not notify context subscribers when identify does not complete', async () => {
createBaseClientMock.mockReturnValue(
makeBaseClient({ identify: jest.fn(() => Promise.resolve({ status: 'error', error: new Error('x') })) }),
);
const client = createClient('env-id', { kind: 'user', key: 'k' });

const onContext = jest.fn();
client.onContextChange(onContext);

await client.identify({ kind: 'user', key: 'new-key' });

expect(onContext).not.toHaveBeenCalled();
});
7 changes: 6 additions & 1 deletion packages/sdk/vue/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -32,22 +32,27 @@
"clean": "rimraf dist",
"build": "tsup",
"lint": "eslint .",
"test": "npx jest --ci --passWithNoTests",
"test": "npx jest --ci",
"coverage": "yarn test --coverage",
"check": "yarn lint && yarn build && yarn test"
},
"dependencies": {
"@launchdarkly/js-client-sdk": "workspace:^"
},
"peerDependencies": {
"vue": "^3.3.0"
},
"devDependencies": {
"@eslint/js": "^9.0.0",
"@types/jest": "^29.5.12",
"@vue/test-utils": "^2.4.6",
"eslint": "^9.0.0",
"eslint-import-resolver-typescript": "^4.0.0",
"eslint-plugin-import-x": "^4.0.0",
"eslint-plugin-jest": "^28.0.0",
"globals": "^16.0.0",
"jest": "^29.7.0",
"jest-environment-jsdom": "^29.7.0",
"rimraf": "^5.0.5",
"ts-jest": "^29.1.1",
"tsup": "^8.5.1",
Expand Down
87 changes: 87 additions & 0 deletions packages/sdk/vue/src/client/LDClient.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,87 @@
import type {
LDClient,
LDContextStrict,
LDWaitForInitializationResult,
} from '@launchdarkly/js-client-sdk';
import type { Ref } from 'vue';

/**
* Represents the current initialization state of the LaunchDarkly client.
*/
export type InitializationStatus = LDWaitForInitializationResult | { status: 'initializing' };

/**
* Initialization state of the client as a string union.
* Derived from {@link InitializationStatus} for consistency.
*/
export type InitializedState = InitializationStatus['status'];

/**
* The LaunchDarkly client interface for Vue.
*
* Extends the base {@link LDClient} with initialization-status and context-change subscriptions that
* the Vue provider and composables use to react to `start()` and `identify()`.
*/
export interface LDVueClient extends LDClient {
/**
* Returns the initialization state of the client. Useful to determine whether the client can be
* used to evaluate flags on initial render.
*/
getInitializationState(): InitializedState;

/**
* Returns the error that caused initialization to fail, if any. Only set when
* {@link getInitializationState} returns `'failed'`.
*/
getInitializationError(): Error | undefined;

/**
* Subscribes to context changes triggered by `identify()`. The callback is invoked after each
* successful `identify()` call (and once after a successful `start()`) with the resolved context.
*
* @returns An unsubscribe function.
*/
onContextChange(callback: (context: LDContextStrict) => void): () => void;

/**
* Subscribes to initialization status changes. The callback fires when `start()` resolves. If the
* client has already resolved, the callback is invoked immediately with the cached result.
*
* @returns An unsubscribe function.
*/
onInitializationStatusChange(
callback: (result: LDWaitForInitializationResult) => void,
): () => void;

/**
* Returns whether the client is ready to evaluate flags. True once initialization has completed
* (successfully or not), or when bootstrap data was provided.
*/
isReady(): boolean;
}

/**
* The reactive value provided to Vue components via inject. Composables read from these refs.
*/
export interface LDVueInstance {
/**
* The LaunchDarkly client.
*/
client: LDVueClient;

/**
* The current LaunchDarkly context. Undefined until the client has initialized.
*/
context: Readonly<Ref<LDContextStrict | undefined>>;

/**
* The initialization state of the client.
*/
initializedState: Readonly<Ref<InitializedState>>;

/**
* The error that caused the client to fail to initialize. Only set when `initializedState` is
* `'failed'`.
*/
error: Readonly<Ref<Error | undefined>>;
}
48 changes: 48 additions & 0 deletions packages/sdk/vue/src/client/LDOptions.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,48 @@
import type { LDOptions, LDStartOptions } from '@launchdarkly/js-client-sdk';
import type { InjectionKey } from 'vue';

import type { LDVueInstance } from './LDClient';

/**
* Options for the underlying LaunchDarkly client.
*/
export type LDVueClientOptions = LDOptions;

/**
* Options for creating a Vue provider.
*/
export interface LDVueProviderOptions {
/**
* Options for the LaunchDarkly client.
*
* @see {@link LDVueClientOptions}
*/
ldOptions?: LDVueClientOptions;

/**
* Options for starting the LaunchDarkly client. Useful when not deferring initialization.
*
* @see {@link LDStartOptions}
*/
startOptions?: LDStartOptions;

/**
* If true, the client will not start automatically. Start it manually via `useLDClient().start()`.
*
* @defaultValue false
*/
deferInitialization?: boolean;

/**
* A custom injection key, for running multiple LaunchDarkly clients in the same application. If not
* provided, the default key is used. Create one with {@link createLDVueInstanceKey}.
*/
injectionKey?: InjectionKey<LDVueInstance>;

/**
* Bootstrap data from the server. When provided, the client immediately uses these values before
* the first network response, eliminating the flag-fetch waterfall on page load. Merged into
* `startOptions.bootstrap`; this top-level value takes precedence.
*/
bootstrap?: unknown;
}
115 changes: 115 additions & 0 deletions packages/sdk/vue/src/client/LDVueClient.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,115 @@
import {
createClient as createBaseClient,
type LDContext,
type LDContextStrict,
type LDIdentifyOptions,
type LDIdentifyResult,
type LDOptions,
type LDStartOptions,
type LDWaitForInitializationResult,
} from '@launchdarkly/js-client-sdk';

import type { InitializedState, LDVueClient } from './LDClient';
import type { LDVueClientOptions } from './LDOptions';

/**
* Creates a new instance of the LaunchDarkly client for Vue.
*
* @remarks
* This factory is provided to allow the caller to own the client lifecycle. When using this
* function, the caller is responsible for calling `client.start()` before or after mounting
* and for subscribing to client lifecycle events.
*
* TODO(scaffold): add recommendation to prefer createLDProvider / LDVuePlugin once those
* arrive in the next PR, and restore the createLDProviderWithClient cross-reference.
*
* @example
* ```ts
* import { createClient } from '@launchdarkly/vue-client-sdk';
*
* const client = createClient('your-client-side-id', { kind: 'user', key: 'user-key' });
* await client.start();
* ```
*
* @param clientSideID the LaunchDarkly client-side ID @see https://launchdarkly.com/docs/sdk/concepts/client-side-server-side#client-side-id
* @param context the initial LaunchDarkly context @see https://launchdarkly.com/docs/sdk/concepts/context
* @param options options for the client @see {@link LDVueClientOptions}
* @returns the new client instance @see {@link LDVueClient}
*/
export function createClient(
clientSideID: string,
context: LDContext,
options: LDVueClientOptions = {},
): LDVueClient {
const baseClientOptions: LDOptions = {
...options,
wrapperName: options.wrapperName ?? 'vue-client-sdk',
wrapperVersion: options.wrapperVersion ?? '0.1.0', // x-release-please-version
};

const baseClient = createBaseClient(clientSideID, context, baseClientOptions);
let initializationState: InitializedState = 'initializing';
let hasBootstrap = false;
let startCalled = false;
let startNotified = false;
const subscribers = new Set<(context: LDContextStrict) => void>();
const initStatusSubscribers = new Set<(result: LDWaitForInitializationResult) => void>();
let lastInitResult: LDWaitForInitializationResult | undefined;

function notifyContextSubscribers() {
const newContext = baseClient.getContext();
if (newContext) {
subscribers.forEach((cb) => cb(newContext));
}
}

return {
...baseClient,
start: (startOptions?: LDStartOptions) => {
// The base client start method is idempotent, so just return its result if already called.
if (startCalled) {
return baseClient.start(startOptions);
}
startCalled = true;
if (startOptions?.bootstrap) {
hasBootstrap = true;
}
return baseClient.start(startOptions).then((result: LDWaitForInitializationResult) => {
initializationState = result.status;
lastInitResult = result;
if (!startNotified) {
startNotified = true;
notifyContextSubscribers();
}
initStatusSubscribers.forEach((cb) => cb(result));
return result;
});
},
identify: (ldContext: LDContext, identifyOptions?: LDIdentifyOptions) =>
baseClient.identify(ldContext, identifyOptions).then((result: LDIdentifyResult) => {
if (result.status === 'completed') {
notifyContextSubscribers();
}
return result;
}),
getInitializationState: () => initializationState,
getInitializationError: () =>
lastInitResult?.status === 'failed' ? lastInitResult.error : undefined,
onContextChange: (callback: (ldContext: LDContextStrict) => void) => {
subscribers.add(callback);
return () => {
subscribers.delete(callback);
};
},
onInitializationStatusChange: (callback: (result: LDWaitForInitializationResult) => void) => {
if (lastInitResult) {
callback(lastInitResult);
}
initStatusSubscribers.add(callback);
return () => {
initStatusSubscribers.delete(callback);
};
},
isReady: () => initializationState !== 'initializing' || hasBootstrap,
};
}
9 changes: 9 additions & 0 deletions packages/sdk/vue/src/client/index.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,9 @@
export type {
InitializationStatus,
InitializedState,
LDVueClient,
LDVueInstance,
} from './LDClient';
export type { LDVueClientOptions, LDVueProviderOptions } from './LDOptions';
export { createClient } from './LDVueClient';
// TODO(scaffold): provider and composables arrive in the next PR
Loading
Loading