diff --git a/packages/react/src/context/use-context-mutator.ts b/packages/react/src/context/use-context-mutator.ts index e089c871a..8c5caeff4 100644 --- a/packages/react/src/context/use-context-mutator.ts +++ b/packages/react/src/context/use-context-mutator.ts @@ -39,6 +39,7 @@ export function useContextMutator(options: ContextMutationOptions = { defaultCon async ( updatedContext: EvaluationContext | ((currentContext: EvaluationContext) => EvaluationContext), ): Promise => { + // TODO: Needs to handle `openfeature` option like OpenFeatureProvider const previousContext = OpenFeature.getContext(options?.defaultContext ? undefined : domain); const resolvedContext = typeof updatedContext === 'function' ? updatedContext(previousContext) : updatedContext; diff --git a/packages/react/src/provider/provider.tsx b/packages/react/src/provider/provider.tsx index 21bfada99..e76f7e273 100644 --- a/packages/react/src/provider/provider.tsx +++ b/packages/react/src/provider/provider.tsx @@ -1,4 +1,4 @@ -import type { Client } from '@openfeature/web-sdk'; +import type { Client, OpenFeatureAPI } from '@openfeature/web-sdk'; import { OpenFeature } from '@openfeature/web-sdk'; import * as React from 'react'; import type { ReactFlagEvaluationOptions } from '../options'; @@ -11,6 +11,11 @@ type ClientOrDomain = * @see OpenFeature.setProvider() and overloads. */ domain?: string; + /** + * An instance of the OpenFeature API to use. + * @see OpenFeature.getIsolated for more details. + */ + openfeature?: OpenFeatureAPI; client?: never; } | { @@ -19,6 +24,7 @@ type ClientOrDomain = */ client?: Client; domain?: never; + openfeature?: never; }; type ProviderProps = { @@ -31,8 +37,8 @@ type ProviderProps = { * @param {ProviderProps} properties props for the context provider * @returns {OpenFeatureProvider} context provider */ -export function OpenFeatureProvider({ client, domain, children, ...options }: ProviderProps) { - const stableClient = React.useMemo(() => client || OpenFeature.getClient(domain), [client, domain]); +export function OpenFeatureProvider({ client, domain, openfeature, children, ...options }: ProviderProps) { + const stableClient = React.useMemo(() => client || (openfeature ?? OpenFeature).getClient(domain), [client, domain]); return {children}; } diff --git a/packages/react/src/provider/test-provider.tsx b/packages/react/src/provider/test-provider.tsx index 82bb6f045..f3fa2faaa 100644 --- a/packages/react/src/provider/test-provider.tsx +++ b/packages/react/src/provider/test-provider.tsx @@ -87,6 +87,7 @@ export function OpenFeatureTestProvider(testProviderOptions: TestProviderProps) const effectiveProvider = ( flagValueMap ? new TestProvider(flagValueMap, testProviderOptions.delayMs) : mixInNoop(provider) || NOOP_PROVIDER ) as Provider; + // TODO: Needs to handle `openfeature` option like OpenFeatureProvider testProviderOptions.domain ? OpenFeature.setProvider(testProviderOptions.domain, effectiveProvider) : OpenFeature.setProvider(effectiveProvider); diff --git a/packages/react/src/provider/use-open-feature-provider.ts b/packages/react/src/provider/use-open-feature-provider.ts index f15d0321e..412516610 100644 --- a/packages/react/src/provider/use-open-feature-provider.ts +++ b/packages/react/src/provider/use-open-feature-provider.ts @@ -17,5 +17,6 @@ export function useOpenFeatureProvider(): Provider { throw new MissingContextError('No OpenFeature context available'); } + // TODO: Needs to handle `openfeature` option like OpenFeatureProvider return OpenFeature.getProvider(openFeatureContext.domain); } diff --git a/packages/web/src/open-feature.ts b/packages/web/src/open-feature.ts index 131f8c273..40e800289 100644 --- a/packages/web/src/open-feature.ts +++ b/packages/web/src/open-feature.ts @@ -39,11 +39,16 @@ export class OpenFeatureAPI } /** - * Gets a singleton instance of the OpenFeature API. + * Gets a instance of the OpenFeature API. * @ignore + * @param {boolean} singleton Whether to get the global (window) singleton instance or an isolated non-singleton instance. * @returns {OpenFeatureAPI} OpenFeature API */ - static getInstance(): OpenFeatureAPI { + static getInstance(singleton = true): OpenFeatureAPI { + if (!singleton) { + return new OpenFeatureAPI(); + } + const globalApi = _globalThis[GLOBAL_OPENFEATURE_API_KEY]; if (globalApi) { return globalApi; @@ -421,8 +426,58 @@ export class OpenFeatureAPI } } +interface OpenFeatureAPIWithIsolated extends OpenFeatureAPI { + /** + * Create a new isolated, non-singleton instance of the OpenFeature API. + * + * By default, the OpenFeature API is exposed as a global singleton instance (stored on `window` in browsers). + * While this can be very convenient as domains, providers, etc., are shared across an entire application, + * this can mean that in multi-frontend architectures (e.g. micro-frontends) different parts of an application + * can think they're loading different versions of OpenFeature, when they're actually all sharing the same instance. + * + * The `getIsolated` method allows different parts of a multi-frontend application to have their own isolated + * OpenFeature API instances, avoiding potential conflicts and ensuring they're using the expected version of the SDK, + * and don't risk colliding with any other usages of OpenFeature on the same page. + * @example + * import { OpenFeature } from '@openfeature/web-sdk'; + * + * OpenFeature.setProvider(new MyGlobalProvider()); // Sets the provider for the default domain on the global instance + * const globalClient = OpenFeature.getClient(); // Uses MyGlobalProvider, the provider for the default domain on the global instance + * + * export const OpenFeatureIsolated = OpenFeature.getIsolated(); // Create a new isolated instance of the OpenFeature API and export it + * OpenFeatureIsolated.setProvider(new MyIsolatedProvider()); // Sets the provider for the default domain on the isolated instance + * const isolatedClient = OpenFeatureIsolated.getClient(); // Uses MyIsolatedProvider, the provider for the default domain on the isolated instance + * + * // In the same micro-frontend, in a different file ... + * import { OpenFeature } from '@openfeature/web-sdk'; + * import { OpenFeatureIsolated } from './other-file'; + * + * const globalClient = OpenFeature.getClient(); // Uses MyGlobalProvider, the provider for the default domain on the global instance + * const isolatedClient = OpenFeatureIsolated.getClient(); // Uses MyIsolatedProvider, the provider for the default domain on the isolated instance + * + * const OpenFeatureIsolatedOther = OpenFeature.getIsolated(); // Create another new isolated instance of the OpenFeature API + * const isolatedOtherClient = OpenFeatureIsolatedOther.getClient(); // Uses the NOOP provider, as this is a different isolated instance + * + * // In another micro-frontend, after the above has executed ... + * import { OpenFeature } from '@openfeature/web-sdk'; + * + * const globalClient = OpenFeature.getClient(); // Uses MyGlobalProvider, the provider for the default domain on the global instance + * + * const OpenFeatureIsolated = OpenFeature.getIsolated(); // Create a new isolated instance of the OpenFeature API + * const isolatedClient = OpenFeatureIsolated.getClient(); // Uses the NOOP provider, as this is a different isolated instance + */ + getIsolated: () => OpenFeatureAPI; +} + +const createOpenFeatureAPI = (): OpenFeatureAPIWithIsolated => + Object.assign(OpenFeatureAPI.getInstance(), { + getIsolated() { + return OpenFeatureAPI.getInstance(false); + }, + }); + /** * A singleton instance of the OpenFeature API. - * @returns {OpenFeatureAPI} OpenFeature API + * @returns {OpenFeatureAPIWithIsolated} OpenFeature API */ -export const OpenFeature = OpenFeatureAPI.getInstance(); +export const OpenFeature = createOpenFeatureAPI(); diff --git a/packages/web/test/isolated.spec.ts b/packages/web/test/isolated.spec.ts new file mode 100644 index 000000000..103b8096b --- /dev/null +++ b/packages/web/test/isolated.spec.ts @@ -0,0 +1,114 @@ +import type { JsonValue, OpenFeatureAPI, Provider, ProviderMetadata, ResolutionDetails } from '../src'; + +const GLOBAL_OPENFEATURE_API_KEY = Symbol.for('@openfeature/web-sdk/api'); + +class MockProvider implements Provider { + readonly metadata: ProviderMetadata; + + constructor(options?: { name?: string }) { + this.metadata = { name: options?.name ?? 'mock-provider' }; + } + + resolveBooleanEvaluation(): ResolutionDetails { + throw new Error('Not implemented'); + } + + resolveNumberEvaluation(): ResolutionDetails { + throw new Error('Not implemented'); + } + + resolveObjectEvaluation(): ResolutionDetails { + throw new Error('Not implemented'); + } + + resolveStringEvaluation(): ResolutionDetails { + throw new Error('Not implemented'); + } +} + +const _globalThis = globalThis as { + [GLOBAL_OPENFEATURE_API_KEY]?: OpenFeatureAPI; +}; + +describe('OpenFeature', () => { + beforeEach(() => { + Reflect.deleteProperty(_globalThis, GLOBAL_OPENFEATURE_API_KEY); + expect(_globalThis[GLOBAL_OPENFEATURE_API_KEY]).toBeUndefined(); + jest.resetModules(); + }); + + afterEach(async () => { + jest.clearAllMocks(); + }); + + it('should persist via globalThis (window in browsers)', async () => { + const firstInstance = (await import('../src')).OpenFeature; + + jest.resetModules(); + const secondInstance = (await import('../src')).OpenFeature; + + expect(firstInstance).toBe(secondInstance); + expect(_globalThis[GLOBAL_OPENFEATURE_API_KEY]).toBe(firstInstance); + }); + + it('can also be accessed via OpenFeatureAPI.getInstance', async () => { + const { OpenFeature, OpenFeatureAPI } = await import('../src'); + + expect(OpenFeature).toBe(OpenFeatureAPI.getInstance()); + }); + + describe('OpenFeature.getIsolated', () => { + it('should not be the same instance as the global singleton', async () => { + const { OpenFeature } = await import('../src'); + + expect(OpenFeature.getIsolated()).not.toBe(OpenFeature); + }); + + it('should not be the same instance as another isolated instance', async () => { + const { OpenFeature } = await import('../src'); + + expect(OpenFeature.getIsolated()).not.toBe(OpenFeature.getIsolated()); + }); + + it('can also be created via OpenFeatureAPI.getInstance', async () => { + const { OpenFeature, OpenFeatureAPI } = await import('../src'); + + expect(OpenFeatureAPI.getInstance(false)).not.toBe(OpenFeature); + }); + + it('should not share state between global and isolated instances', async () => { + const { OpenFeature, NOOP_PROVIDER } = await import('../src'); + const isolatedInstance = OpenFeature.getIsolated(); + + const globalProvider = new MockProvider({ name: 'global-provider' }); + OpenFeature.setProvider(globalProvider); + + expect(OpenFeature.getProvider()).toBe(globalProvider); + expect(isolatedInstance.getProvider()).toBe(NOOP_PROVIDER); + + const isolatedProvider = new MockProvider({ name: 'isolated-provider' }); + isolatedInstance.setProvider(isolatedProvider); + + expect(OpenFeature.getProvider()).toBe(globalProvider); + expect(isolatedInstance.getProvider()).toBe(isolatedProvider); + }); + + it('should not share state between two isolated instances', async () => { + const { OpenFeature, NOOP_PROVIDER } = await import('../src'); + const isolatedInstanceOne = OpenFeature.getIsolated(); + const isolatedInstanceTwo = OpenFeature.getIsolated(); + + const isolatedProviderOne = new MockProvider({ name: 'isolated-provider-one' }); + isolatedInstanceOne.setProvider(isolatedProviderOne); + + expect(isolatedInstanceOne.getProvider()).toBe(isolatedProviderOne); + expect(isolatedInstanceTwo.getProvider()).toBe(NOOP_PROVIDER); + + const isolatedProviderTwo = new MockProvider({ name: 'isolated-provider-two' }); + isolatedInstanceTwo.setProvider(isolatedProviderTwo); + + expect(isolatedInstanceOne.getProvider()).toBe(isolatedProviderOne); + expect(isolatedInstanceTwo.getProvider()).toBe(isolatedProviderTwo); + }); + }); +}); diff --git a/packages/web/test/tsconfig.json b/packages/web/test/tsconfig.json index 379a994d8..146c1b0de 100644 --- a/packages/web/test/tsconfig.json +++ b/packages/web/test/tsconfig.json @@ -1,4 +1,7 @@ { "extends": "../tsconfig.json", - "include": ["."] + "include": ["."], + "compilerOptions": { + "module": "es2020" + } }