diff --git a/packages/toolbar/package.json b/packages/toolbar/package.json index 90082c51..e478b764 100644 --- a/packages/toolbar/package.json +++ b/packages/toolbar/package.json @@ -138,6 +138,7 @@ "@codemirror/lint": "^6.9.3", "@codemirror/state": "^6.5.4", "@codemirror/view": "^6.39.11", + "@launchdarkly/js-client-sdk": "^4.0.0", "@launchdarkly/observability": "^1.0.0", "@launchdarkly/session-replay": "^1.0.0", "@launchpad-ui/components": "^0.17.12", @@ -186,6 +187,7 @@ "@angular/common": ">=14.0.0 <22.0.0", "@angular/core": ">=14.0.0 <22.0.0", "launchdarkly-js-client-sdk": ">=3.9.0 <4.0.0", + "@launchdarkly/js-client-sdk": "^0.11.0", "react": "^18.0.0 || ^19.0.0", "react-dom": "^18.0.0 || ^19.0.0", "vue": "^2.7.0 || ^3.0.0" diff --git a/packages/toolbar/rslib.config.ts b/packages/toolbar/rslib.config.ts index 4608a97e..dfab8f28 100644 --- a/packages/toolbar/rslib.config.ts +++ b/packages/toolbar/rslib.config.ts @@ -87,6 +87,7 @@ export default defineConfig({ '@angular/common': '@angular/common', rxjs: 'rxjs', 'launchdarkly-js-client-sdk': 'launchdarkly-js-client-sdk', + '@launchdarkly/js-client-sdk': '@launchdarkly/js-client-sdk', }, }, plugins: [pluginReact()], diff --git a/packages/toolbar/src/core/tests/FlagSdkOverrideProvider.test.tsx b/packages/toolbar/src/core/tests/FlagSdkOverrideProvider.test.tsx index ef9a21dc..39ab7e95 100644 --- a/packages/toolbar/src/core/tests/FlagSdkOverrideProvider.test.tsx +++ b/packages/toolbar/src/core/tests/FlagSdkOverrideProvider.test.tsx @@ -181,6 +181,41 @@ describe('FlagSdkOverrideProvider', () => { expect(screen.getByTestId('flag-dynamic-flag')).toHaveTextContent('Dynamic Flag: updated (original)'); }); + test('handles LaunchDarkly client change events from client v4', async () => { + mockLdClient.allFlags.mockReturnValue({ + 'dynamic-flag': 'initial', + }); + + let changeHandler: (context: object, changedKeys: string[]) => void; + mockLdClient.on.mockImplementation((event: string, handler: any) => { + if (event === 'change') { + changeHandler = handler; + } + }); + + render( + + + , + ); + + await act(async () => { + await new Promise((resolve) => setTimeout(resolve, 0)); + }); + + expect(screen.getByTestId('flag-dynamic-flag')).toHaveTextContent('Dynamic Flag: initial (original)'); + + mockLdClient.allFlags.mockReturnValue({ + 'dynamic-flag': 'updated', + }); + + await act(async () => { + changeHandler!({}, ['dynamic-flag']); + }); + + expect(screen.getByTestId('flag-dynamic-flag')).toHaveTextContent('Dynamic Flag: updated (original)'); + }); + test('handles null LaunchDarkly client gracefully', async () => { // GIVEN: Plugin returns null client (edge case) (mockFlagOverridePlugin.getClient as any).mockReturnValue(null); diff --git a/packages/toolbar/src/core/ui/Toolbar/components/new/Contexts/AddContextForm.tsx b/packages/toolbar/src/core/ui/Toolbar/components/new/Contexts/AddContextForm.tsx index 8733df75..33b85ddf 100644 --- a/packages/toolbar/src/core/ui/Toolbar/components/new/Contexts/AddContextForm.tsx +++ b/packages/toolbar/src/core/ui/Toolbar/components/new/Contexts/AddContextForm.tsx @@ -1,6 +1,6 @@ import { useState, useCallback, useMemo } from 'react'; import { motion, AnimatePresence } from 'motion/react'; -import type { LDContext } from 'launchdarkly-js-client-sdk'; +import type { LDContext } from '@launchdarkly/js-client-sdk'; import { useContextsContext } from '../../../context/api/ContextsProvider'; import { CancelIcon } from '../../icons'; import { EASING } from '../../../constants'; diff --git a/packages/toolbar/src/core/ui/Toolbar/components/new/Contexts/ContextItem.tsx b/packages/toolbar/src/core/ui/Toolbar/components/new/Contexts/ContextItem.tsx index 719c7ca2..2ccd9640 100644 --- a/packages/toolbar/src/core/ui/Toolbar/components/new/Contexts/ContextItem.tsx +++ b/packages/toolbar/src/core/ui/Toolbar/components/new/Contexts/ContextItem.tsx @@ -1,6 +1,6 @@ import { useState, useCallback, useMemo, useEffect, useRef, memo } from 'react'; import { motion, AnimatePresence } from 'motion/react'; -import type { LDContext } from 'launchdarkly-js-client-sdk'; +import type { LDContext } from '@launchdarkly/js-client-sdk'; import * as styles from './ContextItem.module.css'; import { CopyableText } from '../../CopyableText'; import { EditIcon, DeleteIcon, CheckIcon, CancelIcon } from '../../icons'; diff --git a/packages/toolbar/src/core/ui/Toolbar/context/FlagSdkOverrideProvider.tsx b/packages/toolbar/src/core/ui/Toolbar/context/FlagSdkOverrideProvider.tsx index 9c218682..523614fb 100644 --- a/packages/toolbar/src/core/ui/Toolbar/context/FlagSdkOverrideProvider.tsx +++ b/packages/toolbar/src/core/ui/Toolbar/context/FlagSdkOverrideProvider.tsx @@ -107,16 +107,28 @@ export function FlagSdkOverrideProvider({ children, flagOverridePlugin }: FlagSd setIsLoading(false); // Subscribe to changes with incremental updates - const handleChange = (changes: Record) => { + // NOTE: a better way to do this might be to be able to use the LDPluginEnvironmentMetadata that + // is passed in when the plugin is registered. That property has the client version number which can + // then be used to determine how to adapt the change handler. + // Currently the change handler is set up to be able to handle both <= v3 and >= v4 change events. + // <= v3: changes are passed as the first argument and as a map of flag keys and their changed values. + // >= v4: changes are passed as the second argument (the first argument is the context) and is an array of flag keys + // of changed flags. + const handleChange = (changes: Record, keys?: string[]) => { setFlags((prevFlags) => { const updatedRawFlags = ldClient.allFlags(); const newFlags = buildFlags(updatedRawFlags, apiFlags); + let changedKeys = keys; + if (changedKeys === undefined) { + changedKeys = Object.keys(changes); + } + // Only update the flags that actually changed for better performance const updatedFlags = { ...prevFlags }; let hasChanges = false; - Object.keys(changes).forEach((flagKey) => { + changedKeys.forEach((flagKey) => { if (newFlags[flagKey]) { updatedFlags[flagKey] = newFlags[flagKey]; hasChanges = true; diff --git a/packages/toolbar/src/core/ui/Toolbar/context/api/ContextsProvider.tsx b/packages/toolbar/src/core/ui/Toolbar/context/api/ContextsProvider.tsx index dc935b8f..c1e84659 100644 --- a/packages/toolbar/src/core/ui/Toolbar/context/api/ContextsProvider.tsx +++ b/packages/toolbar/src/core/ui/Toolbar/context/api/ContextsProvider.tsx @@ -1,5 +1,5 @@ import { createContext, useCallback, useContext, useEffect, useMemo, useRef, useState } from 'react'; -import type { LDContext } from 'launchdarkly-js-client-sdk'; +import type { LDContext } from '@launchdarkly/js-client-sdk'; import { loadContexts, saveContexts, loadActiveContext, saveActiveContext } from '../../utils/localStorage'; import { usePlugins } from '../state/PluginsProvider'; import { getContextDisplayName, getContextKey, getContextKind, getStableContextId } from '../../utils/context'; diff --git a/packages/toolbar/src/core/ui/Toolbar/utils/localStorage.ts b/packages/toolbar/src/core/ui/Toolbar/utils/localStorage.ts index 4fd9b24b..cb89eccf 100644 --- a/packages/toolbar/src/core/ui/Toolbar/utils/localStorage.ts +++ b/packages/toolbar/src/core/ui/Toolbar/utils/localStorage.ts @@ -1,4 +1,4 @@ -import type { LDContext } from 'launchdarkly-js-client-sdk'; +import type { LDContext } from '@launchdarkly/js-client-sdk'; import { ToolbarPosition, TOOLBAR_POSITIONS } from '../types/toolbar'; export const TOOLBAR_STORAGE_KEYS = { diff --git a/packages/toolbar/src/core/utils/analytics.ts b/packages/toolbar/src/core/utils/analytics.ts index e83afd06..b6d9774b 100644 --- a/packages/toolbar/src/core/utils/analytics.ts +++ b/packages/toolbar/src/core/utils/analytics.ts @@ -1,5 +1,4 @@ -import type { LDClient } from 'launchdarkly-js-client-sdk'; - +import type { LDClient } from '../../types/plugins/LDClient'; import type { FeedbackSentiment } from '../../types/analytics'; import { isDoNotTrackEnabled } from './browser'; import { sendFeedback } from './feedback'; diff --git a/packages/toolbar/src/core/utils/feedback.ts b/packages/toolbar/src/core/utils/feedback.ts index 3c9e750b..3615a580 100644 --- a/packages/toolbar/src/core/utils/feedback.ts +++ b/packages/toolbar/src/core/utils/feedback.ts @@ -1,5 +1,5 @@ import { LDRecord } from '@launchdarkly/session-replay'; -import type { LDClient } from 'launchdarkly-js-client-sdk'; +import type { LDClient } from '../../types/plugins/LDClient'; export type LDFeedbackSentiment = 'positive' | 'neutral' | 'negative'; diff --git a/packages/toolbar/src/types/plugins/LDClient.ts b/packages/toolbar/src/types/plugins/LDClient.ts new file mode 100644 index 00000000..6a288d8b --- /dev/null +++ b/packages/toolbar/src/types/plugins/LDClient.ts @@ -0,0 +1,19 @@ +import { LDFlagSet, LDPluginEnvironmentMetadata } from 'launchdarkly-js-client-sdk'; +import { LDIdentifyResult, Hook } from '@launchdarkly/js-client-sdk'; + +/** + * LDClient based on the LDClient type in the SDK package with + * a narrower structure to ensure that they can be used by + * our plugins. + */ +export interface LDClient { + track(key: string, data?: any, metricValue?: number): void; + identify(ctx: any): Promise | Promise | Promise; + addHook(hook: Hook): void; + getHooks?(metadata: LDPluginEnvironmentMetadata): Hook[]; + allFlags(): LDFlagSet; + getContext(): any; + on(key: string, callback: (...args: any[]) => void): void; + off(key: string, callback: (...args: any[]) => void): void; + flush(): void; +} diff --git a/packages/toolbar/src/types/plugins/eventInterceptionPlugin.ts b/packages/toolbar/src/types/plugins/eventInterceptionPlugin.ts index 47d2218e..6a5fe929 100644 --- a/packages/toolbar/src/types/plugins/eventInterceptionPlugin.ts +++ b/packages/toolbar/src/types/plugins/eventInterceptionPlugin.ts @@ -1,8 +1,9 @@ -import type { Hook, LDClient, LDPluginEnvironmentMetadata, LDPluginMetadata } from 'launchdarkly-js-client-sdk'; +import type { LDPluginEnvironmentMetadata, LDPluginMetadata, Hook } from '@launchdarkly/js-client-sdk'; import { AfterTrackHook, AfterIdentifyHook, AfterEvaluationHook, EventStore } from '../hooks'; import type { EventFilter, ProcessedEvent } from '../events'; import type { IEventInterceptionPlugin } from './plugins'; import { ANALYTICS_EVENT_PREFIX } from '../analytics'; +import type { LDClient } from './LDClient'; /** * Configuration options for the EventInterceptionPlugin diff --git a/packages/toolbar/src/types/plugins/flagOverridePlugin.ts b/packages/toolbar/src/types/plugins/flagOverridePlugin.ts index 548326f0..5fe5ee3b 100644 --- a/packages/toolbar/src/types/plugins/flagOverridePlugin.ts +++ b/packages/toolbar/src/types/plugins/flagOverridePlugin.ts @@ -1,12 +1,12 @@ import type { - LDClient, LDDebugOverride, LDPluginMetadata, LDFlagSet, Hook, LDPluginEnvironmentMetadata, -} from 'launchdarkly-js-client-sdk'; +} from '@launchdarkly/js-client-sdk'; import type { IFlagOverridePlugin } from './plugins'; +import type { LDClient } from './LDClient'; /** * Configuration options for the FlagOverridePlugin diff --git a/packages/toolbar/src/types/plugins/plugins.ts b/packages/toolbar/src/types/plugins/plugins.ts index 592d6517..1460ce2d 100644 --- a/packages/toolbar/src/types/plugins/plugins.ts +++ b/packages/toolbar/src/types/plugins/plugins.ts @@ -1,7 +1,16 @@ -import type { LDClient, LDDebugOverride, LDFlagSet, LDFlagValue, LDPlugin } from 'launchdarkly-js-client-sdk'; +import type { + LDDebugOverride, + LDFlagSet, + LDFlagValue, + LDPlugin, + LDPluginEnvironmentMetadata, +} from '@launchdarkly/js-client-sdk'; +import type { LDClient } from './LDClient'; import type { ProcessedEvent } from '../events'; -export interface IFlagOverridePlugin extends LDPlugin, LDDebugOverride { +export interface IFlagOverridePlugin extends Omit, LDDebugOverride { + register(client: LDClient, metadata: LDPluginEnvironmentMetadata): void; + /** * Sets an override value for a feature flag * @param flagKey - The key of the flag to override @@ -36,7 +45,8 @@ export interface IFlagOverridePlugin extends LDPlugin, LDDebugOverride { /** * Interface for event interception plugins that can be used with the LaunchDarkly Toolbar */ -export interface IEventInterceptionPlugin extends LDPlugin { +export interface IEventInterceptionPlugin extends Omit { + register(client: LDClient, metadata: LDPluginEnvironmentMetadata): void; /** * Gets all intercepted events from the event store * @returns Array of processed events diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 56831f39..bd236bf6 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -150,6 +150,9 @@ importers: '@codemirror/view': specifier: ^6.39.11 version: 6.39.11 + '@launchdarkly/js-client-sdk': + specifier: ^4.0.0 + version: 4.0.0 '@launchdarkly/observability': specifier: ^1.0.0 version: 1.0.0 @@ -1186,9 +1189,15 @@ packages: '@launchdarkly/js-client-sdk-common@1.16.0': resolution: {integrity: sha512-W6yVVlIV/BqCLR1je3uReVfJcTNK9mznp1PWpEEBRRrapjMnLYUcCJat6yj3dyyn3VKyawXy1MkSn34gEEicjg==} + '@launchdarkly/js-client-sdk-common@1.17.2': + resolution: {integrity: sha512-MV4I8h2a/C6UKxsoPMLQW0ileb/qj30gfCyQl2KsM2ZJGP3hwN2mkKUUwytlL+RURpN+0XGpuQ6UIQFfePUTgQ==} + '@launchdarkly/js-client-sdk@0.11.0': resolution: {integrity: sha512-PRmSsPsOi3NXjXpG4D4MoyImBibNoh9wOoWFbIMXHA26h5cMNBQjTFI6gqe8kyf2rDwqHerhrj5f734Zqwl33Q==} + '@launchdarkly/js-client-sdk@4.0.0': + resolution: {integrity: sha512-jTKbVmfDsx/PsvtFsTpZTjZxp6HP5zQSMnz1okC3z/foVBsC4FxMrlWjMgZnYMNeoe2MOjDHOo6s9eQxI+zCew==} + '@launchdarkly/js-sdk-common@2.20.0': resolution: {integrity: sha512-g1Lyi5xL7AXAP6BP8BzRcqVqIhqOSVpA5Bx8Vvj8A/4A6sIHVz2vIZluykD/bJiKg1+G9ojm+OCfdL/c0ebi0A==} @@ -6017,10 +6026,18 @@ snapshots: dependencies: '@launchdarkly/js-sdk-common': 2.20.0 + '@launchdarkly/js-client-sdk-common@1.17.2': + dependencies: + '@launchdarkly/js-sdk-common': 2.20.0 + '@launchdarkly/js-client-sdk@0.11.0': dependencies: '@launchdarkly/js-client-sdk-common': 1.16.0 + '@launchdarkly/js-client-sdk@4.0.0': + dependencies: + '@launchdarkly/js-client-sdk-common': 1.17.2 + '@launchdarkly/js-sdk-common@2.20.0': {} '@launchdarkly/observability@1.0.0':